From 85c83ec143498874f51a38dc1b6b9986a7749de6 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Fri, 6 Nov 2026 08:41:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=9C=E4=B8=9A=E9=A6=96=E9=A1=B5=E5=92=8C?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E7=8A=B6=E6=80=81=E7=9A=84=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/languages/en-US.json | 1369 ++++++++++++++++- lib/components/device_status_modal.dart | 18 +- lib/components/input_confirm_dialog.dart | 158 +- lib/core/consts/http_api_consts.dart | 17 +- lib/core/di/injection.dart | 89 ++ .../presentation/pages/login_page_v2.dart | 8 +- .../gateways/local_demo_work_gateway.dart | 18 + .../datasources/device_task_datasource.dart | 90 +- .../data/models/device_task_model.dart | 7 +- .../device_task_repository_impl.dart | 13 + .../entities/device_schedule_plan_entity.dart | 143 ++ .../repositories/device_task_repository.dart | 6 + .../presentation/bloc/device_status_bloc.dart | 17 +- .../home/presentation/pages/dj_page.dart | 20 +- .../home/presentation/routes/home_routes.dart | 23 +- .../presentation/widgets/common/enum.dart | 14 + .../pages/machine_details_page.dart | 10 +- .../presentation/main_wrapper.dart | 1 + .../presentation/models/pv_telemetry.dart | 7 +- .../presentation/pages/pv_control_page.dart | 222 ++- .../presentation/widgets/pv_info_column.dart | 7 +- .../widgets/pv_manual_control_view.dart | 140 +- .../widgets/pv_settings_sheet.dart | 63 +- .../presentation/widgets/pv_video_area.dart | 43 +- .../presentation/widgets/pv_work_map.dart | 326 +++- .../widgets/no_device_crosshair_hint.dart | 60 + .../widgets/left_joystick_area.dart | 5 +- .../datasources/device_remote_datasource.dart | 210 ++- .../repositories/device_repository_impl.dart | 42 + .../repositories/device_repository.dart | 18 + .../presentation/cubit/find_robot_cubit.dart | 48 + .../presentation/cubit/find_robot_state.dart | 29 + .../cubit/float_bar_setting_cubit.dart | 6 +- .../float_bar/view/float_bar_widget.dart | 11 +- .../pages/device_status_page.dart | 50 +- .../pages/device_status_page_v2.dart | 557 ++++--- .../pages/drone_video_control_page.dart | 21 +- .../presentation/pages/no_device_page_v2.dart | 120 +- .../widgets/find_robot_sheet.dart | 210 ++- .../v2/my/presentation/pages/my_page_v2.dart | 127 +- lib/main.dart | 15 +- 41 files changed, 3702 insertions(+), 656 deletions(-) create mode 100644 lib/features/devices/domain/entities/device_schedule_plan_entity.dart create mode 100644 lib/features/regions/presentation/widgets/no_device_crosshair_hint.dart create mode 100644 lib/features/v2/device_list/presentation/cubit/find_robot_cubit.dart create mode 100644 lib/features/v2/device_list/presentation/cubit/find_robot_state.dart diff --git a/assets/languages/en-US.json b/assets/languages/en-US.json index 5e5cd89d..bd956505 100644 --- a/assets/languages/en-US.json +++ b/assets/languages/en-US.json @@ -1,6 +1,22 @@ { "app_name": "Maibu Mowing Robot", + "device_control_mode": { + "title": "Confirm control mode", + "unknown_mode": "The current mode has not been reported. Press OK to request %s.", + "current": "Current live mode", + "target": "Mode required by this page", + "waiting": "Waiting for the device to report its control mode…", + "submitting": "Sending the mode change…", + "awaiting_report": "Request sent. Waiting for the device to confirm…", + "matched": "The current mode is correct. Press OK to enter.", + "mismatch": "The current mode does not match this page. Press OK to switch to %s.", + "retry": "Refresh live mode", + "invalid_device": "Device code is empty. Go back and select a device.", + "no_report": "No valid live control mode received. Check the device connection and retry.", + "request_timeout": "The mode change request timed out. Check the live mode before retrying.", + "switch_unconfirmed": "The request was accepted, but the device has not reported the requested mode. Check its status before retrying." + }, "common": { "confirm": "Confirm", "cancel": "Cancel", @@ -47,6 +63,24 @@ "understood": "Got it", "protocol_detail_text": "Your %s details will appear here..." }, + + "register": { + "title": "Sign Up", + "subtitle": "Fill in the following information to sign up", + "username": "Username", + "username_hint": "Set your username", + "password": "Password", + "password_hint": "Set your password", + "confirm_password": "Confirm Password", + "confirm_password_hint": "Enter your password again", + "submit": "Sign Up", + "has_account": "Already have an account?", + "login_now": "Log in now", + "incomplete": "Please complete all the required information", + "password_mismatch": "The two passwords do not match", + "developing": "Registration is under development...", + "agree_hint": "Please check and agree before creating a new account" + }, "home": { "title": "Home", @@ -110,6 +144,7 @@ "valid": "Valid", "local_control": "Local Control", "remote_control": "Remote Control", + "autonomous_task": "Autonomous Task", "data_refresh_success": "Data refreshed!", "no_device": "No device available", "left_right_measure_speed": "Left/Right Measure Speed", @@ -260,7 +295,20 @@ "forward_backward": "Forward/Backward", "left_right": "Left/Right", "emergency_stop": "E-Stop", - "emergency_stopping": "Stopping" + "emergency_stopping": "Stopping", + "perm_unknown_device": "Unknown device", + "perm_unknown_platform": "Unknown platform", + "permission_request_body": "{platform} is requesting control\n\nDevice ID: {deviceId}\n\nRelease control?", + "toast_released": "Remote control permission released", + "toast_release_failed": "Failed to release control permission", + "mode_picker_title": "Select control mode", + "mode_minimal": "Minimal mode", + "mode_minimal_sub": "Classic remote control screen", + "mode_full": "Full mode", + "mode_full_sub": "Landscape dashboard control", + "video_load_failed": "Video failed to load. Check the device is online or retry.", + "video_loading": "Loading video...", + "joystick_no_data": "No telemetry from this machine yet; control unavailable" }, "pv_control": { @@ -370,7 +418,9 @@ "set_sim_obstacle": "Simulate Obstacle", "set_sim_obstacle_sub": "Alert: banner + box + red flash", "set_sim_reverse": "Reverse Camera", - "set_sim_reverse_sub": "Overlay reverse guide lines", + "set_sim_reverse_sub": "Overlay reverse guide lines on the main view", + "set_show_joysticks": "Joysticks", + "set_show_joysticks_sub": "Hide both direction joysticks and recenter", "perm_unknown_device": "Unknown device", "perm_unknown_platform": "Unknown platform", "perm_request_body": "{platform} is requesting control\n\nDevice ID: {deviceId}\n\nRelease control?", @@ -389,7 +439,15 @@ "axis_forward": "Forward", "axis_backward": "Backward", "axis_left": "Turn Left", - "axis_right": "Turn Right" + "axis_right": "Turn Right", + "network_good": "Network good", + "network_bad": "Network issue", + "video_not_loaded": "Video not loaded", + "obstacle_alert": "Obstacle alert", + "no_device": "No device selected", + "unit_m": "m", + "unit_km": "km", + "task_patrol_running": "Patrol task in progress" }, "route_planning": { @@ -547,6 +605,7 @@ "action_pause": "Pause", "action_return": "Return", "task_in_progress": "In Progress", + "task_default": "Inverter area patrol", "checklist_title": "Checklist", "signal_strong": "Strong", "view_front": "Front", @@ -560,10 +619,971 @@ "path_planning_desc": "View and edit the robot patrol path", "release_permission_success": "Remote control permission released", "release_permission_failed": "Failed to release control permission", + "open_bluetooth_first": "Please turn on Bluetooth first", + "searching_bluetooth": "Searching for Bluetooth devices...", "stat_online_devices": "Online Devices", "stat_working": "Working", "stat_standby": "Standby", - "stat_offline": "Offline" + "stat_offline": "Offline", + "no_matching_device": "No matching devices", + "feature_todo": "Feature not available yet", + "rename_failed": "Failed to rename device", + "rename_failed_code": "Failed to rename device: status code %s", + "rename_success": "Device renamed to \"%s\"", + "no_site_selected": "No site selected", + "site_suffix": " (%s)", + "site_suffix_personal": " (%s) · Personal account", + "personal_single_site": "Personal accounts are bound to a single site", + "no_site_data": "No site data", + "select_site": "Select site", + "my_robots": "My robots", + "my_robots_desc": "Unified multi-model management · Features loaded by device SN", + "view_card": "Card view", + "view_overview": "Overview", + "search_hint_full": "Search device name, model or SN", + "overview_title": "Device overview", + "overview_desc": "Built for centralized multi-robot management", + "stat_total": "Total devices", + "stat_alarm_offline": "Alarm/Offline", + "device_distribution": "Device distribution", + "total_devices_count": "%s devices in total", + "th_device_info": "Device", + "th_status": "Status", + "th_location_task": "Location / Task", + "add_robot": "Add robot", + "scan_action": "Scan", + "add_device": "Add device", + "local_video": "Local video", + "usb_camera": "USB camera", + "drone_station_model": "Drone dock · %s", + "no_device_title": "No robots added yet", + "no_device_desc": "You can explore the app first,\nand bind your robot once it arrives.", + "guide_title": "Getting started", + "guide1_title": "Product guide", + "guide1_desc": "Learn the basic features and workflow", + "guide2_title": "Watch product videos", + "guide2_desc": "Get started with control, video and work features", + "guide3_title": "Help & after-sales", + "guide3_desc": "FAQ, repair requests and customer support", + "work_text_working": "Autonomous work in progress", + "work_text_online": "Standby", + "work_text_offline": "Unable to connect", + "sn_copied": "SN copied to clipboard", + "edition_plus": "PLUS Smart", + "edition_std": "STD Standard", + "edition_pro": "PRO Flagship", + "location_plant_1": "Plant 1", + "location_plant_2": "Plant 2", + "location_raw_material": "Raw material area", + "location_office_building": "Office building", + "rename_title": "Rename device", + "rename_label": "Device name", + "rename_empty": "Name cannot be empty", + "rename_hint": "Chinese, English, digits and common symbols are supported; 1-%s characters recommended", + "rename_quick": "Quick naming", + "rename_field_hint": "Enter device name", + "rename_footer": "Only the display name changes; SN, binding and permissions stay unchanged", + "preset_1": "Unit 1", + "preset_2": "Unit 2", + "preset_north": "North area", + "preset_south": "South area", + "preset_test": "Test robot", + "preset_custom": "Custom", + "action_cancel": "Cancel", + "action_confirm": "Confirm", + "action_save": "Save", + "fr_title": "Find robot", + "fr_gnss_valid": "GNSS fix valid", + "fr_gnss_invalid": "GNSS fix invalid", + "fr_model_std": "%s Standard", + "fr_map_entry": "View robot on map", + "fr_map_entry_sub": "Location and last update time", + "fr_sound_title": "Sound & light locating", + "fr_sound_desc": "Triggers buzzer and indicator lights so on-site staff can find the device; stops automatically.", + "fr_auto_stop": "Auto stop", + "fr_auto_stop_value": "10s (demo)", + "fr_start": "Start locating alert", + "fr_stop": "Stop locating", + "fr_note": "Sound & light locating is unavailable while the device is offline; only the last known location can be viewed.", + "fr_toast_offline": "Device is offline, cannot trigger locating alert", + "fr_toast_start_todo": "Locating command is not connected yet", + "fr_toast_start_sent": "Locating command sent, watch for the device sound & light alert", + "fr_toast_start_failed": "Failed to send locating command, please retry", + "fr_toast_stop_todo": "Stop-locating command is not connected yet", + "fr_toast_map_todo": "Map location entry is not connected yet", + "unknown": "Unknown" + }, + + "device_detail_v2": { + "title": "Device details", + "tab_overview": "Overview", + "tab_monitor": "Monitor", + "tab_control": "Control", + "tab_record": "Records", + "tab_settings": "Settings", + "footer_settings": "Settings opened by model (%s)", + "footer_demo": "Sample data; quick actions are navigation entries only", + "overview_title": "Robot status overview", + "view_detail": "View details", + "stat_energy": "Energy", + "stat_control_mode": "Control mode", + "stat_chip_temp": "Chip temperature", + "stat_blade_speed": "Blade speed", + "stat_voltage": "Battery voltage", + "stat_gnss": "GNSS fix", + "mode_remote": "Remote", + "mode_local": "Local", + "location_task_title": "Position & current task", + "view_map": "View map", + "current_location": "Current location: %s", + "location_hint": "Current position only; tasks follow the control page", + "live_monitor": "Live monitoring", + "data_update_sec": "Updated %ss ago", + "unknown_version": "Unknown", + "sensor_status": "Sensor status", + "sensor_status_trailing": "Configured for this SN", + "st_valid": "Valid", + "st_normal": "Normal", + "st_not_established": "Not established", + "st_no_fault": "No fault", + "m_gnss_title": "GNSS basic positioning", + "m_gnss_sub": "Position valid · Basic positioning (non-RTK)", + "m_ultrasonic_title": "Ultrasonic obstacle detection", + "m_ultrasonic_sub": "4 sensors · No obstacle alert currently", + "m_imu_title": "Chassis IMU", + "m_imu_sub": "Attitude reporting normal · Monitoring only", + "comm_link": "Communication links", + "comm_link_hint": "Data and video links shown separately", + "m_cloud_title": "Cloud data connection", + "m_cloud_sub": "Lightweight channel for status and alerts", + "m_rc_title": "Physical remote controller link", + "m_rc_sub": "Receiver online · Control not taken over", + "m_video_title": "Video streaming session", + "m_video_sub": "Video page not opened, no stream", + "m_mcu_title": "Lower controller & relay board", + "m_mcu_sub": "Key control communication status", + "actuator_title": "Actuators & current faults", + "fault_detail": "Fault details", + "m_blade_title": "Blade feedback", + "m_blade_sub": "Stopped · Safety interlock normal", + "m_active_fault_title": "Active faults", + "m_active_fault_sub": "0 items · No active alert detected", + "grid_title": "Sensor & communication monitoring", + "g_camera_title": "Perception camera", + "g_camera_sub": "4 channels · Image normal", + "g_ultrasonic_sub": "Detection normal · No obstacle", + "g_gnss_title": "GNSS/RTK", + "g_gnss_sub": "Fixed solution · Error <2cm", + "g_motion_title": "Motion state", + "g_motion_sub": "Attitude and speed normal", + "g_mcu_title": "MCU communication", + "g_mcu_sub": "Controller connected", + "g_cloud_sub": "Data reporting normal", + "g_ai_title": "Basic person/vehicle recognition", + "g_ai_sub": "Recognition module working", + "g_rc_title": "Remote control link", + "g_rc_sub": "Video and control channels normal", + "g_heartbeat_title": "Device heartbeat", + "g_heartbeat_sub": "Last report: 2s ago", + "quick_diag": "Quick diagnostics", + "self_check": "Device self-check", + "self_check_sub": "Last check OK · 2024-10-19 09:32", + "check_now": "Check now", + "ctrl_video_remote": "Video & remote control", + "ctrl_on_demand": "Connect on demand", + "ctrl_realtime_video": "Open live video", + "ctrl_realtime_video_sub": "View live footage", + "ctrl_need_permission": "Enter manually and request permission", + "ctrl_remote": "Open remote control", + "ctrl_remote_sub": "Operate the robot remotely", + "ctrl_note": "Live video and remote control start only on their dedicated pages; they never start automatically.", + "ctrl_task": "Task & operation control", + "ctrl_route": "Open path planning", + "ctrl_route_sub": "Set operation routes", + "ctrl_route_hint": "Auto planning and editing supported", + "ctrl_task_mgr": "Task management", + "ctrl_task_mgr_sub": "Create and run mowing tasks", + "ctrl_task_mgr_hint": "Scheduled tasks supported", + "ctrl_ops": "Quick controls", + "op_task_start": "Start task", + "op_task_start_sub": "Begin operation", + "op_task_pause": "Pause task", + "op_task_pause_sub": "Pause current task", + "op_return_home": "Return home", + "op_return_home_sub": "Back to charging station", + "op_find": "Find robot", + "op_find_sub": "Locate the robot quickly", + "op_manual_switch": "Manual/Auto switch", + "op_manual_switch_sub": "Switch control mode", + "op_robot_info": "Robot info", + "op_robot_info_sub": "View detailed configuration", + "op_app_power": "APP power", + "op_app_power_sub": "Remote power control", + "op_voice": "Voice broadcast", + "op_voice_sub": "Remote voice announcement", + "op_light": "Lights & sound", + "op_light_sub": "Control lights and warnings", + "rec_fault_title": "Fault & alert records", + "rec_work_title": "Operation records", + "view_all": "View all", + "rec_7d": "Faults/alerts in 7 days", + "rec_7d_sub": "3 records (1 fault, 2 alerts)", + "rec_count": "%s items", + "rec_today": "Today's operation", + "rec_date": "%s/%s", + "rec_area": "Area", + "rec_duration": "Duration", + "rec_efficiency": "Efficiency", + "fault_state": "Fault status", + "fault_none": "No faults", + "fault_none_sub": "Device running normally, no anomaly detected", + "fn_title": "Quick actions", + "fn_edit_order": "Edit order", + "fn_realtime_video": "Live video", + "fn_remote": "Remote control", + "fn_find": "Find robot", + "fn_app_power": "APP power", + "fn_fault": "Fault diagnosis", + "fn_sensor": "Sensor monitoring", + "all_functions": "View all functions", + "set_group_device": "Device settings & upgrade", + "set_param": "Device parameters", + "set_param_sub": "Operation, chassis and blade parameters", + "set_firmware": "Firmware & OTA upgrade", + "set_firmware_sub": "Current version V1.1.2 · Check for updates", + "set_firmware_sub_old": "Current version V0.9.7 · Requires downtime", + "set_check_update": "Check update", + "set_group_blade": "Operation & blade strategy", + "set_manage_perm": "Admin permission", + "set_obstacle_stop": "Blade stop on obstacle", + "set_obstacle_stop_sub": "Enabled · Stop blade by strategy on obstacle", + "set_enabled": "Enabled", + "set_blade_resume": "Blade resume strategy", + "set_blade_resume_sub": "Manual confirmation (recommended)", + "set_manual_confirm": "Manual confirm", + "set_chassis": "Chassis lift parameters", + "set_chassis_sub": "Configured by model and limit signals", + "set_view": "View", + "set_cancel": "Cancel", + "set_group_comm": "Communication & video settings", + "set_network": "Network & communication", + "set_network_sub": "4G/5G/Wi-Fi and GNSS communication", + "set_video": "Video transmission", + "set_video_sub": "Quality, channel and lane guide lines", + "set_group_map": "Positioning & map settings", + "set_map": "Positioning & map parameters", + "set_map_sub": "GNSS parameters, map and work areas", + "set_group_safety": "Safety settings", + "set_auto_stop": "Auto blade stop on obstacle", + "set_auto_stop_sub": "Stop the blade automatically when an obstacle is detected", + "set_auto_resume": "Auto blade resume after clearing", + "set_auto_resume_sub": "Resume mowing once the obstacle is removed", + "set_alarm": "Alarm & light settings", + "set_alarm_sub": "Volume, light prompts and alert methods", + "set_group_control": "Control & video preferences", + "set_rc_pref": "Remote control preferences", + "set_rc_pref_sub": "Layout and speed level limit", + "set_config": "Configure", + "set_video_pref": "Video display preferences", + "set_light_pref": "Light & sound preferences", + "set_light_pref_sub": "Only non-critical prompts can be adjusted", + "set_group_advanced": "Advanced capabilities", + "set_ai": "AI recognition", + "set_ai_sub": "Person/vehicle recognition, sensitivity and areas", + "set_follow": "Person following", + "set_follow_sub": "Target lock, follow mode and distance", + "set_remote_ctrl": "Remote control settings", + "set_remote_ctrl_sub": "Preferences, layout and video latency", + "set_group_maintain": "Network & system maintenance", + "set_net_loc": "Network & positioning", + "set_net_loc_sub": "4G/Wi-Fi and GNSS communication", + "unbind": "Unbind device", + "unbinding": "Unbinding…", + "unbind_confirm_body": "After unbinding you can no longer monitor or control \"%s\". Rebinding is required. Unbind now?", + "unbind_confirm_ok": "Unbind", + "unbind_success": "Unbound %s", + "unbind_error": "Unbind failed: %s" + }, + + "device_pages_v2": { + "title": "All functions", + "empty": "No matching function", + "search_hint": "Search functions", + "notice_prefix": "Current device: ", + "notice_suffix": ". Only functions supported by this device are shown; this version does not provide auto planning, task dispatch or return-home.", + "group_video": "Video & actions", + "group_info": "Device info", + "group_monitor": "Device monitoring", + "live_video": "Live video", + "remote": "Remote control", + "find": "Find robot", + "voice": "Voice broadcast", + "app_power": "APP power", + "follow": "Person following", + "return_home": "Return home", + "robot_info": "Robot info", + "location_map": "Location & map", + "voice_preset": "Voice presets", + "run_record": "Operation records", + "sensor": "Sensor monitoring", + "fault": "Fault diagnosis", + "step_scan": "Scan device", + "step_confirm": "Confirm info", + "step_bind": "Bind & activate", + "bind_title": "Confirm device info", + "bind_banner": "Device recognized, please confirm its info and ownership", + "bind_sn": "Device serial number", + "bind_status": "Binding status", + "bind_conn": "Connection", + "bind_area": "Service region", + "bind_verify_title": "Ownership verification", + "bind_verify_desc": "Scanning the SN does not grant control. Activation credentials or local confirmation on the device are required before binding.", + "bind_next": "Verify ownership and continue", + "qr_title": "Add robot", + "qr_scan_title": "Scan device QR code", + "qr_scan_desc": "Place the QR code on the robot inside the frame", + "qr_flash": "Flash", + "qr_album": "From album", + "qr_manual": "Or enter SN manually", + "qr_sn_hint": "Enter device SN, e.g. SA260002", + "qr_confirm": "Recognize", + "qr_not_arrived": "Robot not delivered yet?", + "qr_not_arrived_desc": "Try the APP first and add the device once it arrives", + "qr_skip_later": "Skip for now, add later →", + "qr_skip": "Skip for now", + "qr_err_no_code": "No QR code recognized, try a clearer image", + "qr_err_sn_empty": "Please enter the device SN" + }, + + "device_ownership_v2": { + "title": "Ownership & Security", + "card_basic": "Device Basics", + "basic_name": "Device Name", + "basic_org": "Organization", + "basic_site": "Site", + "basic_owner": "Owner", + "unknown": "Unknown", + "owner": "Owner", + "unsynced": "Not synced", + "type": "Ownership Type", + "type_org": "Enterprise", + "bound_active": "Bound · Online", + "bound_offline": "Bound · Offline", + "sn": "Device SN", + "first_claim": "First Claim", + "admin": "Administrator", + "member_enter": "Sharing", + "card_permission": "My Permissions", + "role_current": "Role: %s", + "perm_desc": "Ownership follows Organization → Site → Owner. Your role can only act on the level within its scope.", + "perm_level_org": "Organization", + "perm_level_site": "Site", + "perm_level_user": "Owner", + "perm_self_short": "Self only", + "perm_self_only": "Self only (cannot transfer to others)", + "perm_transfer_to": "Can transfer to", + "perm_unbind_to": "Can unbind up to", + "card_actions": "Ownership Actions", + "risky_transfer": "Transfer Device", + "risky_transfer_sub": "Re-attach to an organization, site or owner", + "risky_unbind": "Unbind Device", + "risky_unbind_sub": "Remove the device from its current level (irreversible)", + "tag_locked": "No access", + "action_locked": "Your role has no permission for this action", + "footer": "Administrator is not the device owner. The SN is only an identifier and cannot complete an unbind. Real ownership data appears once the ownership API is connected.", + "toast_member_todo": "Member sharing management is not connected yet", + "transfer_device_title": "Transfer Device", + "transfer_device_msg": "Transferring re-attaches the device to the selected level and replaces the current ownership link. Continue?", + "transfer_device_note": "The device identity must be verified by scanning first. Regular users can only transfer to themselves.", + "transfer_device_ok": "Start Transfer", + "unbind_device_title": "Unbind Device", + "unbind_device_msg": "After unbinding, your link to this device expires immediately and re-binding is required. Continue?", + "unbind_device_note": "The device identity must be verified by scanning first; the SN cannot replace identity verification.", + "unbind_device_ok": "Start Unbind" + }, + + "device_ownership_action_v2": { + "title_transfer": "Transfer Device", + "title_unbind": "Unbind Device", + "card_scan": "Scan Verification", + "scan_device_code": "Device Code", + "scan_result": "Scanned Value", + "scan_state": "Verification", + "scan_unscanned": "Not scanned · using current device", + "scan_matched": "Matches current device", + "scan_mismatch": "Does not match current device", + "scan_mismatch_hint": "The scanned value differs from the current device code. Submitting will act on the scanned device instead; make sure you did not scan the wrong one.", + "card_scope_transfer": "Level to Attach To", + "card_scope_unbind": "Ownership Level to Remove", + "scope_org": "Organization", + "scope_site": "Site", + "scope_user": "Owner", + "scope_opt_user": "Individual", + "scope_opt_site": "Site", + "scope_opt_org": "Organization", + "scope_opt_user_sub": "Only removes the owner's link to the device", + "scope_opt_site_sub": "Removes the site and everything below it", + "scope_opt_org_sub": "Removes the whole organization link, widest impact", + "scope_locked_hint": "Out of your role's permission, cannot be selected", + "tag_locked": "No access", + "scope_select_tip": "Unbinding only does one thing: detach the device from the selected level. Re-binding is required to restore the link.", + "unassigned_org": "No organization selected", + "unassigned_site": "No site selected", + "unassigned_user": "No owner selected", + "role_current": "Role: %s", + "role_admin": "Administrator", + "role_manager": "Organization Manager", + "role_site_manager": "Site Manager", + "role_personal": "Personal Account", + "role_user": "Regular User", + "scope_tip_transfer": "Select level by level (Organization → Site → Owner); the device is attached to the deepest level you pick. Leaving the owner empty attaches it to the organization or site only.", + "scope_tip_unbind": "Select level by level (Organization → Site → Owner). Locked levels are only used for context and are never removed. To remove an upper level, pick \"Not specified\" in the picker.", + "scope_max_transfer": "Your role can attach up to: %s", + "scope_max_unbind": "Your role can remove up to: %s", + "picker_clear_transfer": "Not specified (skip this level)", + "picker_clear_unbind": "Not specified (remove one level up)", + "preview_transfer": "Will be attached to:", + "preview_unbind": "This will remove:", + "picker_empty": "No options available", + "submit_transfer": "Confirm Transfer", + "submit_unbind": "Confirm Unbind", + "toast_transfer": "Device transferred", + "toast_unbind": "Device unbound", + "mismatch_title": "Scanned Device Differs", + "mismatch_msg": "Scanned value %s does not match the current device %s. Continuing will act on the scanned device. Continue?", + "mismatch_note": "This action changes device ownership. Make sure the scanned device is the one you selected.", + "mismatch_ok": "Proceed Anyway", + "footer_transfer": "Transfer equals binding: the device is attached to the selected level and the previous ownership link is replaced.", + "footer_unbind": "Unbinding is irreversible: the device is detached from the selected level and must be bound again to restore access." + }, + + "robot_power_v2": { + "title": "APP Power & Linkage Control", + "state_on": "Powered on", + "state_off": "Powered off", + "state_off_short": "Off", + "sec_remote_power": "Remote Power", + "power_help": "Power Help", + "sec_linkage": "Linkage Actions", + "unlocked": "Unlocked, ready to use", + "locked_hint": "Power on first", + "sec_unlock": "Controls Unlocked After Power On", + "unlock_hint": "Unlocked automatically after remote power on", + "cur_state": "Current Status", + "powered_desc": "Linkage actions are unlocked: blade, chassis, lights and horn are available.", + "unpowered_desc": "Power on remotely first to use blade, chassis, E-stop, lights and horn.", + "btn_power_on": "Power On", + "btn_power_off": "Power Off", + "lk_blade": "Blade", + "lk_chassis": "Chassis Lift", + "lk_estop": "Emergency Stop", + "lk_light": "Lights", + "lk_horn": "Horn / Voice", + "act_start": "Start", + "act_stop": "Stop", + "act_raise": "Raise", + "act_lower": "Lower", + "act_estop": "E-Stop", + "act_on": "On", + "act_off": "Off", + "act_horn": "Horn", + "act_voice": "Voice", + "tile_device_off": "Device is off", + "tile_operable": "Ready", + "find_tile": "Find Robot", + "find_locked": "Needs power on", + "find_start": "Start Finding", + "more": "More", + "footer": "Remote power and motion control require safety authentication. Ensure the area is clear first.", + "toast_channel_tip": "The %s channel is not connected yet (requires remote control authority and ignition command). The state shown is a preview only.", + "toast_channel_short": "The %s channel is not connected yet", + "toast_already_on": "Device is already powered on", + "toast_already_off": "Device is currently powered off", + "toast_offline_on": "Device is offline; the power-on command was not sent", + "toast_offline_off": "Device is offline; the power-off command was not sent", + "toast_need_power": "Power on remotely first to run %s", + "toast_need_power_voice": "Power on remotely first to broadcast voice", + "toast_more_unlock": "More unlocked features will be released in later versions", + "toast_power_help": "Blade, chassis, E-stop, lights and horn are available only after remote power on", + "toast_safety_help": "Remote power and motion control require device-side safety authentication. Make sure nobody is nearby.", + "power_on_title": "Confirm Remote Power On", + "power_on_msg": "Send a remote power-on command to the robot? Blade, chassis lift and E-stop become available only after power on.", + "power_on_note": "Make sure the surrounding area is safe and the network is stable.", + "power_on_ok": "Power On", + "power_off_title": "Confirm Remote Power Off", + "power_off_msg": "Send a remote power-off command to the robot? Blade, chassis, lights and horn will stop working immediately.", + "power_off_note": "Make sure the device has stopped working and is in a safe position.", + "power_off_ok": "Power Off" + }, + + "robot_sensor_v2": { + "title": "Sensors & Connectivity", + "todo": "This feature is not available yet", + "tab_ultrasonic": "Ultrasonic", + "tab_position": "Position", + "tab_comm": "Network", + "tab_mechanism": "Mechanism", + "sec_ultrasonic": "Ultrasonic Live Status", + "us_card_title": "Ultrasonic Obstacle Detection", + "us_ok_count": "6/6 normal", + "us_note": "Distances below are demo data; real sensor layout is loaded per device SN.", + "sec_distance": "Sensor Ranging", + "dir_front_left": "Front-left", + "dir_front_mid": "Front-center", + "dir_front_right": "Front-right", + "dir_left": "Left", + "dir_right": "Right", + "dir_rear": "Rear", + "dist_normal": "%s · normal", + "sonar_front": "Front: 1.8m", + "sonar_rear": "Rear: 2.1m", + "sec_position": "Position & Satellites", + "row_lng": "Longitude", + "row_lat": "Latitude", + "row_fix": "Fix Type", + "row_fix_value": "Built-in GNSS", + "row_fix_state": "Fix Status", + "fix_loading": "Loading…", + "fix_valid": "Valid · meter level", + "fix_none": "Not reported", + "row_sats": "Satellites", + "row_sats_value": "Example: 18", + "row_rtk": "RTK Correction", + "row_rtk_value": "Example: disabled", + "sec_comm": "Network Link", + "row_online": "Device Online", + "online": "Online", + "offline": "Offline", + "row_comm_mode": "Connection", + "row_comm_mode_value": "Example: MQTT long connection", + "row_net_type": "Network Type", + "row_net_type_value": "Example: 4G", + "row_signal": "Signal Strength", + "row_signal_value": "Example: -68 dBm", + "row_latency": "Link Latency", + "row_latency_value": "Example: 120 ms", + "row_last_report": "Last Report", + "row_last_report_value": "Example: 2026-10-20 09:41", + "sec_mechanism": "Working Mechanism", + "row_blade_lift": "Blade Deck Lift", + "row_blade_lift_value": "Example: stopped", + "row_blade_rpm": "Blade Speed", + "row_blade_rpm_value": "Example: 0 r/min", + "row_tilt": "Chassis Tilt", + "row_tilt_value": "Example: normal", + "row_obstacle": "Obstacle Detection", + "row_obstacle_value": "Example: not triggered", + "row_drive": "Drive System", + "row_drive_value": "Example: normal", + "row_estop": "E-Stop Switch", + "row_estop_value": "Example: not pressed", + "sample_note": "Fields marked \"Example\" are not reported by the backend yet and will update automatically once connected" + }, + + "robot_fault_v2": { + "title": "Fault Diagnosis Center", + "todo": "This feature is not available yet", + "sec_current": "Current Faults & Alerts", + "sec_filter": "Fault Filter", + "sec_support": "Diagnosis Support", + "cur_none": "No unresolved faults", + "cur_unknown": "Current fault status unknown", + "cur_sub_online": "Latest valid report · %s", + "cur_sub_offline": "Device offline, telemetry interrupted · checked at %s", + "st_normal": "Normal", + "st_unknown": "Unknown", + "filter_current": "Current (0)", + "filter_recovered": "Recovered (%s)", + "filter_all": "All Records", + "list_recovered": "Recovered Events", + "list_all": "All Records", + "list_recent_recovered": "Recently Recovered", + "empty_offline": "Device offline, no records available", + "empty": "No records", + "recovered_suffix": "%s · recovered", + "badge_recovered": "Recovered", + "support_codes": "View Fault Codes", + "support_codes_sub": "Description, impact and troubleshooting", + "support_sensor": "Sensor & Network Live Status", + "support_sensor_sub": "Check current link and hardware feedback", + "note": "When offline or telemetry is stale, never show \"No faults\"; label it as \"status unknown\" instead. Recovered events are demo data and will update once the fault record API is connected.", + "sheet_codes_title": "Fault Codes", + "sheet_codes_hint": "Tap any code to see description, impact and troubleshooting", + "sheet_problem": "Description", + "sheet_impact": "Impact & Advice", + "sheet_ok": "Got it", + "fc_c201_name": "Brief 4G signal loss", + "fc_c201_desc": "The 4G link between the device and the cloud dropped for a short time and recovered automatically after reconnecting.", + "fc_c201_impact": "Impact: remote commands and status reports are delayed; work itself is not affected.", + "fc_c201_advice": "Action: check 4G coverage in the work area, inspect the antenna connection, and replace the SIM if it happens often.", + "fc_u108_name": "Brief ultrasonic comms error", + "fc_u108_desc": "The ultrasonic sensor serial link briefly lost packets and recovered once the link was restored.", + "fc_u108_impact": "Impact: obstacle distance refresh lags; low-speed work can continue meanwhile.", + "fc_u108_advice": "Action: check the sensor harness and connectors, clean the sensor surface, replace the sensor if needed.", + "ev_time_1": "Oct 19, 18:23", + "ev_time_2": "Oct 17, 15:42" + }, + + "robot_light_v2": { + "title": "Lights & Sound", + "info_tip": "Lights and horn require a control channel on the device; currently a UI preview only", + "sec_light": "Light Control", + "sec_sound": "Sound & Light Alerts", + "sec_auto": "Automation", + "pill_on": "Lights on", + "pill_off": "All lights off", + "headlight": "Headlight", + "headlight_sub": "Work and night lighting for the travel direction", + "warning_light": "Warning Light", + "warning_light_sub": "Amber strobe to alert people nearby during work", + "marker_light": "Marker Light", + "marker_light_sub": "Body outline hint so others can see the machine at night", + "act_on_headlight": "Turn on headlight", + "act_off_headlight": "Turn off headlight", + "act_on_warning": "Turn on warning light", + "act_off_warning": "Turn off warning light", + "act_on_marker": "Turn on marker light", + "act_off_marker": "Turn off marker light", + "act_horn": "Horn", + "volume": "Alarm Volume", + "vol_low": "Low", + "vol_mid": "Medium", + "vol_high": "High", + "volume_set": "Volume set to \"%s\" (local preference, not sent to the device)", + "horn_dur": "Horn Duration", + "dur_3": "3 s", + "dur_5": "5 s", + "dur_10": "10 s", + "horn_set": "Horn duration set to \"%s\" (local preference, not sent to the device)", + "test_horn": "Test horn %s", + "auto_night": "Auto Light at Night", + "auto_night_sub": "Turn on headlight and marker light automatically in low light", + "auto_night_on": "Auto light at night enabled (local policy, not sent to the device)", + "auto_night_off": "Auto light at night disabled (local policy, not sent to the device)", + "auto_work": "Warning Light During Work", + "auto_work_sub": "Turn on the warning light when a task starts and off when it ends", + "auto_work_on": "Warning light during work enabled (local policy, not sent to the device)", + "auto_work_off": "Warning light during work disabled (local policy, not sent to the device)", + "auto_battery": "Low Battery Alert", + "auto_battery_sub": "Sound the horn and strobe the warning light below 20% battery", + "auto_battery_on": "Low battery alert enabled (local policy, not sent to the device)", + "auto_battery_off": "Low battery alert disabled (local policy, not sent to the device)", + "offline_tip": "Device is offline; the %s command was not sent", + "channel_tip": "The %s channel is not connected yet (no light control on the device); the state shown is a preview only", + "footer": "The remote protocol does not expose light or horn commands yet: switches here only reflect the UI state, and volume, horn duration and automation are local preferences. Real control requires the device side to expose these channels." + }, + + "robot_firmware_v2": { + "title": "Software & Firmware Update", + "info_tip": "Updating requires the device to be online, powered stably and safely stopped", + "sec_version": "Current Versions", + "sec_check": "Update Check", + "sec_prereq": "Before You Update", + "sec_history": "Update History", + "row_mcu": "Controller MCU", + "row_comm": "Comms Module", + "row_video": "Video Module", + "row_cfg": "Config Version", + "check_title": "System Update Check", + "check_sub": "Device must be online, powered and safely stopped", + "check_idle": "Not checked", + "check_checking": "Checking…", + "check_btn": "Check for Updates", + "prereq_1": "Robot is safely stopped or idle", + "prereq_2": "Blade and chassis actuators have stopped", + "prereq_3": "Power is sufficient and the link is stable", + "prereq_4": "Current account has update permission", + "rec_success": "Success", + "rec_done": "Completed", + "mod_mcu": "MCU v1.0.3", + "mod_comm": "Comms Module v1.1.2", + "mod_video": "Video Module v1.0.8", + "record_open_tip": "Update details for %s are not available yet", + "toast_offline": "Device is offline; cannot check for updates", + "toast_ota_todo": "The OTA service is not connected yet, so no version result is returned", + "footer": "Version numbers and history are demo data; real values appear once the device version API and OTA channel are connected. Update actions always ask for confirmation first." + }, + + "robot_network_v2": { + "title": "Network & Location", + "info_tip": "Network configuration depends on the device; this page only shows read-only info", + "todo": "The configuration channel is not connected yet", + "tab_network": "Network", + "tab_gnss": "GNSS", + "sec_net_current": "Current Network", + "sec_net_cfg": "Network Configuration", + "sec_gnss_state": "Fix Status", + "sec_coord": "Coordinates", + "sec_gnss_cfg": "Location Settings & Map", + "row_conn_mode": "Connection Mode", + "val_cellular": "4G cellular", + "val_disconnected": "Not connected", + "row_net_state": "Network Status", + "val_online_good": "Online · good signal", + "val_offline": "Offline", + "row_cloud": "Cloud Connection", + "val_normal": "Normal", + "val_broken": "Disconnected", + "row_heartbeat": "Last Heartbeat", + "val_just_now": "Just now", + "val_no_heartbeat": "No heartbeat", + "row_video_session": "Video Session", + "val_no_session": "No viewing session", + "entry_4g": "4G Module Info", + "entry_4g_sub": "Carrier, signal strength and module state", + "entry_wifi": "Wi-Fi Settings", + "entry_wifi_sub": "Available if the device has Wi-Fi hardware", + "entry_cloud": "Cloud Access", + "entry_cloud_sub": "Server and heartbeat read-only / limited settings", + "note_net": "Connection mode and cloud status come from the device online state; carrier and signal strength appear once the device reports them.", + "row_fix_module": "GPS Module", + "val_builtin_gnss": "Built-in GNSS", + "row_cur_fix": "Current Fix", + "fix_loading": "Loading…", + "fix_valid": "Valid · meter level", + "fix_none": "No fix", + "row_sat": "Satellite Info", + "val_sat_only": "Only real device reports", + "row_fix_time": "Last Fix Time", + "row_lat": "Latitude", + "row_lng": "Longitude", + "entry_map": "View on Map", + "entry_map_sub": "Pin, track and last update time", + "entry_gnss_param": "Location Parameters", + "entry_gnss_param_sub": "Coordinate system, report rate (device side)", + "note_loading": "Getting the device location…", + "note_from_device": "Coordinates come from real GNSS reports and are synced to Location & Map", + "note_error": "Failed to get location: %s", + "note_no_fix": "The device has not reported a valid fix yet" + }, + + "robot_run_v2": { + "title": "Run Records & Health", + "todo": "This feature is not available yet", + "custom_todo": "Custom time ranges are not available yet", + "sec_overview": "Run Overview", + "sec_events": "History & Events", + "footer": "Run statistics and history are demo data and will update once the statistics API is connected", + "range_7": "7 days", + "range_30": "30 days", + "range_90": "90 days", + "range_custom": "Custom", + "chart_7": "Run Trend (Last 7 Days)", + "chart_30": "Run Trend (Last 30 Days)", + "chart_90": "Run Trend (Last 90 Days)", + "axis_mon": "Mon", + "axis_tue": "Tue", + "axis_wed": "Wed", + "axis_thu": "Thu", + "axis_fri": "Fri", + "axis_sat": "Sat", + "axis_sun": "Sun", + "axis_week_1": "W1", + "axis_week_2": "W2", + "axis_week_3": "W3", + "axis_week_4": "W4", + "axis_week_5": "W5", + "axis_month_7": "Jul", + "axis_month_8": "Aug", + "axis_month_9": "Sep", + "stat_runtime": "Total Runtime", + "stat_runtime_value": "128.5h", + "stat_starts": "Start Count", + "stat_starts_value": "86 times", + "stat_mileage": "Mileage", + "stat_mileage_value": "12.8km", + "chart_daily": "Daily Runtime", + "chart_unit": "Unit: hours", + "ev_1_title": "Robot powered on", + "ev_1_time": "Today 08:41", + "ev_1_tag": "Status", + "ev_2_title": "APP remote session released", + "ev_2_time": "Yesterday 17:06", + "ev_2_tag": "Control", + "ev_3_title": "Ultrasonic blade-stop triggered", + "ev_3_time": "Oct 18, 15:42", + "ev_3_tag": "Cleared", + "ev_4_title": "Cloud connection restored", + "ev_4_time": "Oct 18, 15:40", + "ev_4_tag": "Network" + }, + + "robot_full_info_v2": { + "title": "Robot Full Info", + "todo": "This feature is not available yet", + "sec_profile": "Basic Profile", + "sec_runtime": "Runtime & Control", + "footer": "Power type, last report, control session and fault count are demo values; they will be replaced once connected", + "model_suffix": "%s · STD", + "tagline": "Dedicated video · Ultrasonic & light · Basic GNSS", + "pill_online": "Online · %s", + "pill_offline": "Offline · %s", + "mode_idle": "Standby", + "sn_masked": "%s · · · · %s (masked)", + "row_name": "Device Name", + "row_sn": "Device SN", + "row_bind": "Binding", + "row_type": "Category", + "row_power": "Power Type", + "row_report": "Last Report", + "val_bound": "Authorized", + "val_unbound": "Unbound", + "val_type": "Tracked mowing robot", + "val_power": "Demo: Electric", + "val_report": "Demo: 2026-10-20 09:41", + "stat_energy": "Energy", + "stat_mode": "Run Mode", + "stat_session": "Control Session", + "val_none": "None", + "row_gnss": "GNSS Fix", + "val_gnss_ok": "Fix valid", + "val_gnss_none": "No fix", + "row_fault": "Active Faults", + "val_fault_count": "0" + }, + + "robot_voice_v2": { + "title": "Voice Presets", + "todo": "This feature is not available yet", + "sec_list": "Preset Voices", + "add": "+ New", + "sec_edit": "Edit Voice Content", + "sec_realtime": "Live Broadcast (Phase 2)", + "footer": "Presets are stored on this device; speech synthesis is a device capability and plays only when supported", + "name_label": "Voice Name", + "name_hint": "e.g. Safety warning", + "text_label": "Voice Text (editable)", + "text_hint": "Enter the text to broadcast", + "language": "Language: Simplified Chinese", + "sync_note": "Synthesis/sync requires device support", + "save": "Save Preset", + "toast_name_required": "Please enter a voice name", + "toast_text_required": "Please enter the voice text", + "toast_saved": "Preset saved", + "toast_preview": "Preview requires on-device speech synthesis, not yet available", + "toast_realtime": "Live broadcast will be available in phase 2", + "realtime_title": "Live broadcast via APP microphone", + "realtime_sub": "Capture voice with the APP microphone and play it on the device in real time", + "badge_todo": "Coming soon", + "def_safety_name": "Safety warning", + "def_safety_text": "Caution: the robot is working, please keep a safe distance.", + "def_start_name": "Start reminder", + "def_start_text": "The device is about to start, please stay away from the work area.", + "def_polite_name": "Polite notice", + "def_polite_text": "Please keep clear, thank you for your cooperation.", + "sheet_title": "Voice Broadcast", + "sheet_manage": "Manage/Edit Presets", + "sheet_note": "Hold-to-talk live broadcast will be available later; presets are always kept. This mode does not open the mic automatically or require video monitoring.", + "tab_preset": "Presets · Phase 1", + "tab_realtime": "Live · Later", + "toast_realtime_later": "Live broadcast will be available later", + "hold_title": "Hold the microphone to talk", + "hold_sub": "Coming later: hold to talk, release to broadcast on the device" + }, + + "robot_location_v2": { + "title": "Location & Map", + "todo": "This feature is not available yet", + "sec_current": "Current Location & Map", + "trailing_loc": "Location Info", + "sec_data": "Location Data", + "map_title": "Device Location", + "map_sub": "Map is illustrative; the marker comes from the device's actual GNSS data", + "pill_fix": "GNSS fix", + "pill_no_fix": "No fix", + "row_lng": "Longitude", + "row_lat": "Latitude", + "row_mode": "Positioning", + "val_mode": "Built-in GNSS", + "row_fix": "Fix Status", + "val_fix_ok": "Valid · meter level", + "val_fix_none": "Not reported", + "row_sat": "Satellite Info", + "val_sat": "Only device-reported data", + "note_loading": "Getting device location…", + "note_from_device": "Coordinates come from the device's actual GNSS report", + "note_error": "Failed to get location: %s", + "note_no_fix": "The device has not reported a valid fix yet" + }, + + "robot_bind_v2": { + "title": "Bind & Activate", + "step_identify": "Identify", + "step_confirm": "Confirm", + "step_bind": "Activate", + "equip_id": "Selected Equipment ID", + "org": "Organization", + "site": "Site", + "owner": "Owner", + "role_current": "Role: %s (%s)", + "role_admin": "Administrator", + "role_manager": "Org Administrator", + "role_site_manager": "Site Administrator", + "role_user": "Standard User", + "unassigned_org": "No organization assigned", + "unassigned_site": "No site assigned", + "unassigned_owner": "No owner assigned", + "please_select": "Please select", + "select_org_first": "Select an organization first", + "select_site_first": "Select a site first", + "submit": "Confirm Binding", + "bind_success": "Bound successfully" + }, + + "ble_detail_v2": { + "title": "Device Details", + "dlg_occupied_title": "Another device connected", + "dlg_occupied_msg": "Currently connected: %s\n\nDisconnect it before connecting a new device.", + "dlg_ok": "Got it", + "err_no_name": "Unable to get the Bluetooth device name", + "err_no_match": "No device found whose name contains \"%s\"", + "err_no_device_id": "Matched device is missing a device ID", + "err_bind_failed": "Failed to bind device: %s", + "unknown_device": "Unknown device", + "cmd_remote": "Remote control", + "cmd_path": "Path planning", + "cmd_status": "Status info", + "cmd_get_id": "Query ID", + "cmd_get_auth": "Get authorization", + "cmd_read_cfg": "Read config", + "cmd_write_cfg": "Write config", + "cmd_obstacle": "Obstacle avoidance", + "cmd_heartbeat": "Heartbeat", + "cmd_unknown": "Unknown (0x%s)", + "row_name": "Device Name", + "row_rssi": "Signal Strength", + "row_adv_name": "Advertising Name", + "row_conn": "Connection", + "state_connected": "Connected", + "state_connecting": "Connecting %s...", + "state_connecting_short": "Connecting %s", + "state_disconnected": "Disconnected", + "sec_actions": "Actions", + "btn_disconnect": "Disconnect", + "btn_connect": "Connect", + "btn_bind": "Bind Device", + "btn_read_cfg": "Read Config", + "sec_services": "Services", + "sec_commands": "Send Command", + "sec_push": "Data Pushes", + "clear": "Clear", + "waiting": "Waiting for data...", + "sec_config": "Device Config", + "writing": "Writing...", + "write_cfg": "Write Config", + "toast_not_editable": "Editing this field is not supported", + "edit_title": "Edit %s", + "edit_hint": "Enter a new value", + "edit_current": "Current: %s", + "cancel": "Cancel", + "confirm": "OK", + "err_no_chip": "Device serial number not available, read the config first", + "err_no_permission": "Permission check failed, cannot write config", + "err_readback_timeout": "Read-back timed out, please read the config manually", + "err_mismatch": "Verification mismatch: %s, values restored to the device's actual values", + "write_ok": "Config written and confirmed by read-back", + "write_error": "Failed to write config: %s", + "list_sep": ", " }, "alarm_center": { @@ -611,7 +1631,8 @@ "high": "High", "medium": "Medium", "low": "Low", - "progress": "Progress" + "progress": "Progress", + "no_data": "No data" }, "my_v2": { @@ -632,7 +1653,343 @@ "about_us": "About Us", "suspend_bar": "Float Bar", "suspend_bar_desc": "Show device status float bar at the bottom", - "tab_settings": "Tab Settings" + "tab_settings": "Tab Settings", + "default_user_name": "SATA User", + "todo_developing": "This feature is not available yet", + "service_support": "Support", + "account_settings": "Account & Settings", + "after_sales_center": "After-sales Service", + "after_sales_center_desc": "Repairs, work orders and warranty", + "maintenance": "Maintenance", + "maintenance_desc": "Plans, reminders and records", + "help_guide": "Help & Guide", + "help_guide_desc": "Manuals, tutorials and FAQ", + "account_security": "Account & Security", + "account_security_desc": "Email/phone, password and devices", + "message_notice": "Notifications", + "message_notice_desc": "Device, work, service and auth messages", + "general_settings": "General Settings", + "general_settings_desc": "Language, units, region and app preferences", + "about_legal": "About & Legal", + "about_legal_desc": "App version, terms and privacy policy", + "stat_processing_order": "Open Orders", + "stat_maintenance_reminder": "Maintenance", + "stat_tutorial": "Guides", + "stat_usage_help": "Help", + "unknown": "N/A", + "unread_unknown": "Unread N/A", + "personal_account": "Personal account", + "contact_not_set": "No contact info", + "edit_profile": "Edit Profile", + "footer_note": "Design prototype · Demo data only · Not linked to real devices or accounts", + "note_card": "Device sharing, member authorization, admin transfer and unbinding reuse the existing flows in the Device module; they are not re-implemented here.", + "logout": "Log Out", + "logout_confirm": "Are you sure you want to log out?", + "profile_title": "Personal Info", + "not_logged_in": "Not signed in", + "user_id": "User ID", + "username": "Username", + "nickname": "Nickname", + "email": "Email", + "phone": "Phone", + "edit_profile_action": "Edit Profile", + "edit_in_progress": "Editing is under development...", + "version_update": "Version Update", + "up_to_date": "You are on the latest version", + "current_version": "Current version: %s", + "checking_update": "Checking for updates...", + "tap_to_check": "Tap to check for a new version", + "check_update": "Check for Updates" + }, + + "work_v2": { + "title": "Work", + "tag": "SATA · Smart O&M", + "overview": "Work Overview", + "all_work_robots": "All work robots", + "no_site_selected": "No site selected", + "site_prefix": "Site: ", + "site_summary_area": "%s work zones", + "site_summary_robot": "%s robots ready", + "switch": "Switch", + "no_site_data": "No site data", + "select_site": "Select Site", + "stat_running": "Running", + "stat_pending": "Pending", + "stat_done_today": "Done Today", + "tag_normal": "Normal", + "tag_to_schedule": "To schedule", + "tag_task": "Tasks", + "section_current": "Current Work", + "all_tasks": "All Tasks", + "section_features": "Work Features", + "toast_todo": "This feature is not available yet", + "toast_loading_devices": "Loading the device list, please try again shortly", + "demo_zone_a_task": "Zone A · Autonomous mowing", + "demo_zone_b_task": "Zone B · Morning mowing", + "demo_robot_1": "Shandong Unit 01", + "demo_robot_2": "Shandong Unit 02", + "demo_robot_3": "Shandong Unit 03", + "demo_route_a1": "Zone A Route 01", + "demo_route_b1": "Zone B Route 01", + "demo_task_zone_a": "Zone A · Autonomous mowing", + "demo_task_c_trim": "Zone C · Nursery trimming", + "demo_task_c_retry": "Zone C · After-rain touch-up", + "demo_task_all_edge": "Whole site · Edge trimming", + "demo_task_a_inspect": "Zone A · Spot inspection", + "demo_task_d_edge": "Zone D · Boundary trimming", + "demo_tomorrow": "Tomorrow 08:00", + "done_progress": "Completed %s", + "demo_done_value": "1.7 / 2.5 mu", + "demo_remain": "About 40 min left", + "enter_work_center": "Open Work Center", + "view_work_plan": "View Work Plan", + "feature_map": "Work Map", + "feature_area": "Zones", + "feature_route_library": "Route Library", + "feature_plan": "Dispatch Work Plans", + "feature_record": "Work Records", + "feature_autonomous": "Autonomous", + "create_task": "New Route", + "no_current_task": "No current work", + "no_current_task_sub": "You can switch the site", + "task_pool": { + "no_user": "Sign in to view work tasks", + "no_site": "Select a site first", + "load_failed": "Unable to load tasks", + "retry": "Retry", + "refresh": "Refresh tasks", + "task_title": "Task #%s", + "device": "Device: %s", + "route": "Work route #%s", + "created": "Created: %s", + "paused": "Paused", + "failed": "Failed", + "canceling": "Canceling", + "unknown_status": "Unknown status", + "view_task": "View task", + "device_load_failed": "Unable to load devices. Please retry.", + "device_missing": "The device was not found in the current site" + }, + + "area": { + "title": "Zones", + "new": "New", + "page_title": "Work Zone Management", + "page_subtitle": "Draw work zones, no-go zones and obstacle zones in the map editor", + "filter_all": "All zones", + "kind_work": "Work zone", + "kind_forbidden": "No-go zone", + "kind_obstacle": "Obstacle zone", + "empty": "No zones in this category yet", + "disabled": "Disabled", + "view": "View", + "edit": "Edit", + "boundary_points": "%s boundary points", + "applies_to": "Applies to: %s", + "detail_todo": "Zone detail API not connected (demo)", + "demo_zone_a": "Zone A · Main lawn", + "demo_zone_b": "Zone B · East lawn", + "demo_zone_c": "Zone C · Nursery", + "demo_zone_all": "Whole site", + "demo_area_pool": "Pool no-go zone", + "demo_area_toolshed": "Tool shed obstacle", + "demo_all_devices": "All devices" + }, + + "map": { + "title": "Work Map", + "subtitle": "Robots, zones and tasks on one site map", + "online_count": "%s online", + "layer_all": "All layers", + "layer_area": "Work zones", + "layer_robot": "Robots", + "layer_forbidden": "No-go zones", + "layer_route": "Routes", + "basemap_label": "Orthophoto · Shandong site", + "toast_record_todo": "Work record entry not connected (demo)", + "toast_layer_todo": "Layer settings follow the top filter (demo)", + "toast_center_reset": "Back to site center (demo)", + "task_device": "Device: %s · task running", + "manage_area": "Manage zones", + "toast_work_center_todo": "Open the Work Center from device details (demo)" + }, + + "plan": { + "title": "Dispatch Work Plans", + "new": "New", + "page_title": "Task List & Schedule", + "page_subtitle": "Tasks arranged by date; running tasks update progress in real time", + "filter_all": "All", + "status_running": "Running", + "status_pending": "Pending", + "status_done": "Completed", + "status_aborted": "Aborted", + "empty": "No plans with this status", + "day_today": "Today", + "day_tomorrow": "Tomorrow", + "day_yesterday": "Yesterday", + "weekday_mon": "Mon", + "weekday_tue": "Tue", + "weekday_wed": "Wed", + "weekday_thu": "Thu", + "weekday_fri": "Fri", + "weekday_sat": "Sat", + "weekday_sun": "Sun", + "task_count": "%s tasks", + "start": "Start", + "detail": "Details", + "toast_calendar_todo": "Calendar view API not connected (demo)", + "toast_new_todo": "Task scheduling API not connected (demo)", + "toast_detail_todo": "Plan detail API not connected (demo)" + }, + + "plan_create": { + "title": "New Work Task", + "dispatch_route": "Routes", + "dispatch_area": "Areas", + "execution_timing": "Execution Mode", + "timing_immediate": "Immediate", + "timing_scheduled": "Scheduled", + "scheduled_hint": "Set a cycle and date range. The device will run at the scheduled time.", + "immediate_hint": "Open the work center with the selected device and route, then tap Start Work to begin.", + "area_immediate_hint": "Select the device and area, then confirm the work details.", + "selection_changed": "The station or device assignment changed. Reopen this page to select the device and route again.", + "task_info": "Task Details", + "schedule_settings": "Schedule Settings", + "area_label": "Work Area", + "area_pick": "Coming soon", + "area_pending": "Area dispatch is coming soon. Choose Routes to schedule a task or open the work center.", + "immediate_pending": "Immediate dispatch is coming soon. Choose Scheduled to create a work plan.", + "coming_soon": "Coming soon", + "submit_now": "Dispatch Now", + "subtitle": "Pick device and route, set the cycle, work starts automatically on time", + "name_label": "Plan Name", + "name_hint": "e.g. Zone A daily morning mowing", + "device_label": "Device", + "device_pick": "Select a device", + "device_empty": "No devices under this station", + "device_sheet_title": "Select Device", + "route_label": "Work Route", + "route_pick": "Select a route", + "route_loading": "Routes are loading, please wait", + "route_sheet_title": "Select Work Route", + "route_unsupported": "This route does not support scheduled tasks", + "no_site": "Please select a station on the home page first", + "cycle_label": "Cycle Type", + "type_day": "Daily", + "type_week": "Weekly", + "type_month": "Monthly", + "week_hint": "Choose one or more weekdays", + "month_hint": "Choose days of month (1-31; skipped if the month has no such day)", + "time_label": "Execution Time", + "valid_label": "Valid Range", + "start_date": "Start Date", + "end_date": "End Date", + "submit": "Create Scheduled Task", + "success": "Work plan created", + "failed": "Create failed, please try again later" + }, + + "record": { + "title": "Work Records", + "page_subtitle": "Every job archived by month; open one to see its report", + "filter_all": "All", + "filter_done": "Completed", + "filter_aborted": "Aborted", + "filter_week": "This week", + "empty": "No records match this filter", + "summary_title": "This Month", + "summary_count": "Jobs", + "summary_area": "Total area (mu)", + "summary_hours": "Working hours", + "report": "Report", + "toast_export_todo": "Bulk export API not connected (demo)" + }, + + "report": { + "title": "Work Report", + "toast_share_todo": "Share API not connected (demo)", + "time_subtitle": "2026-10-26 08:00 — 10:42 · %s", + "trace_badge": "Actual work route", + "trace_replay": "Route playback", + "metric_area": "Work area", + "metric_duration": "Duration", + "metric_coverage": "Coverage", + "metric_power": "Power used", + "info_title": "Job Info", + "info_zone": "Work zone", + "info_route": "Work route", + "info_device": "Device", + "info_start": "Start time", + "info_end": "End time", + "demo_route_pattern": "%s · Back-and-forth", + "event_title": "Process Log", + "event1_title": "Task dispatched", + "event1_desc": "Device accepted the task and ran a self-check", + "event2_title": "Entered Zone B and started", + "event2_desc": "Advancing back and forth along %s", + "event3_title": "Mowing height adjusted", + "event3_desc": "Manual intervention: mowing height 45 → 50 mm", + "event4_title": "Job finished and returned", + "event4_desc": "Back at the charging dock, 66% battery left", + "back_to_records": "Back to records", + "export": "Export report", + "toast_export_todo": "Report export API not connected (demo)" + }, + + "route_lib": { + "title": "Route Library", + "new": "New", + "page_subtitle": "Coverage routes grouped by zone, ready to apply to a new task", + "filter_all": "All routes", + "kind_coverage": "Coverage", + "kind_return": "Return", + "kind_edge": "Edge", + "kind_custom": "Custom", + "loading": "Loading routes…", + "reload": "Reload", + "empty_site": "No routes under this station yet", + "no_site": "Please select a station on the home page first", + "empty": "No routes in this category yet", + "recent": "Recent %s", + "used_today": "today", + "used_yesterday": "yesterday", + "edit": "Edit", + "apply_to_task": "Apply to task", + "apply_todo": "Task scheduling API not connected (demo)", + "demo_route_a1": "Zone A Route 01", + "demo_route_b1": "Zone B Route 01", + "demo_route_b_edge": "Zone B Edge 01", + "demo_route_c_cov": "Zone C Coverage 01", + "demo_route_all_return": "Whole-site Return Route" + } + }, + + "ble_v2": { + "already_connected_title": "Device already connected", + "already_connected_msg": "Currently connected: %s\n\nPlease disconnect it before connecting a new device.", + "got_it": "Got it", + "connected_toast": "Connected to %s", + "title": "Bluetooth Devices", + "connected_current": "Connected: %s", + "scanning_nearby": "Scanning nearby devices...", + "turn_on_bt_first": "Please turn on Bluetooth", + "bt_on": "Bluetooth is on", + "bt_off": "Bluetooth is off", + "connected_device": "Connected device", + "scanning_nearby_short": "Scanning nearby devices", + "turn_on_bt_to_scan": "Turn on Bluetooth to scan nearby devices", + "go_turn_on": "Turn on", + "nearby_devices": "Nearby Devices", + "search_hint": "Search device name or MAC", + "bt_off_cannot_scan": "Bluetooth is off, cannot scan", + "no_match": "No matching devices", + "scanning_dots": "Scanning nearby devices...", + "connected": "Connected", + "disconnect": "Disconnect", + "connect": "Connect", + "tip": "Choose the on-site device to avoid connecting to unrelated devices" }, "help_feedback": { diff --git a/lib/components/device_status_modal.dart b/lib/components/device_status_modal.dart index 4e0505cc..e582106e 100644 --- a/lib/components/device_status_modal.dart +++ b/lib/components/device_status_modal.dart @@ -15,6 +15,7 @@ import '../features/devices/presentation/bloc/devices_cubit.dart'; import '../features/remote_control/presentation/bloc/remote_control_cubit.dart'; import '../features/remote_control/presentation/bloc/remote_control_state.dart'; import '../core/logging/log_time.dart'; +import 'control_mode_text.dart'; const int DATA_TIMEOUT_SECONDS = 15; @@ -406,8 +407,10 @@ class _DeviceStatusModalState extends State { ).translate('running_status.control_mode'), ), _buildTableCell( - AppLocalizations.of(context).translate( - _getControlModeKey(int.tryParse(status.controlMode) ?? 0), + // 统一走共用映射:后端没给 = 未知,不再兜成「本地控制」 + controlModeDisplayText( + AppLocalizations.of(context), + status.controlMode, ), ), ], @@ -457,14 +460,9 @@ class _DeviceStatusModalState extends State { ); } - String _getControlModeKey(int modeValue) { - switch (modeValue) { - case 3: - return 'running_status.remote_control'; - default: - return 'running_status.local_control'; - } - } + // 控制模式文案已下沉到 lib/components/control_mode_text.dart, + // 与运行状态页、设备详情、PV 遥控等共用一份映射(原来此处 default 一律当「本地控制」, + // 把 0=未知、2=自主任务和「后端根本没给」三种情况全显示成了本地)。 @override Widget build(BuildContext context) { diff --git a/lib/components/input_confirm_dialog.dart b/lib/components/input_confirm_dialog.dart index e12977bd..274b96d0 100644 --- a/lib/components/input_confirm_dialog.dart +++ b/lib/components/input_confirm_dialog.dart @@ -19,65 +19,121 @@ Future showInputConfirmDialog({ required Function(String inputText) onConfirm, String? Function(String inputText)? inputValidator, double? maxWidth, + String? initialText, }) async { - // 输入框控制器 - final TextEditingController controller = TextEditingController(); + // 输入框控制器(initialText 非空时预填,用于改名等回显场景) + final TextEditingController controller = TextEditingController( + text: initialText ?? '', + ); await showDialog( context: context, builder: (BuildContext context) { - return AlertDialog( - title: Text(title), - content: SizedBox( - // maxWidth 为空时不加约束,保持原有行为 - width: maxWidth, - child: TextField( - controller: controller, - decoration: InputDecoration( - hintText: hintText, - border: const OutlineInputBorder(), - labelText: labelText, + // 横屏页面(如区域编辑器)键盘常占半屏:AlertDialog 的 title/actions 不随 + // scrollable 滚动,短屏下会被撑出 RenderFlex 溢出、输入框/按钮被裁而“不好改”。 + // 改为自定义 Dialog:键盘弹出时贴顶对齐 + 整体限高 + 字段区可滚,按钮永远可见; + // 没键盘时(如竖屏)仍居中,不改变原有观感。 + final MediaQueryData mq = MediaQuery.of(context); + final double keyboardInset = mq.viewInsets.bottom; + final bool lifted = keyboardInset > 0; + // 弹窗可用最大高度:屏高 - 键盘 - 顶部安全区,再夹到一个合理区间 + final double maxDialogHeight = (mq.size.height - keyboardInset - 24).clamp( + 140.0, + 520.0, + ); + final double contentWidth = (maxWidth ?? 280) + 40; // 加左右 20 padding + return Dialog( + alignment: lifted ? Alignment.topCenter : Alignment.center, + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + insetPadding: EdgeInsets.fromLTRB( + 24, + mq.padding.top + (lifted ? 12 : 24), + 24, + keyboardInset + 12, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxDialogHeight, + maxWidth: contentWidth, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + // 字段区单独可滚:极矮屏下也能滚到输入框,不会把标题/按钮顶出去 + Flexible( + child: SingleChildScrollView( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText, + border: const OutlineInputBorder(), + labelText: labelText, + ), + autofocus: true, // 自动聚焦输入框 + ), + ), + ), + const SizedBox(height: 6), + // 按钮行固定在底部,始终可见可点 + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // 取消按钮 + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(cancelText), + ), + // 确认按钮 + TextButton( + onPressed: () { + // 获取输入文本并去空格 + final String inputText = controller.text.trim(); + + // 1. 执行自定义校验 + if (inputValidator != null) { + final String? errorMsg = inputValidator(inputText); + if (errorMsg != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMsg), + backgroundColor: Colors.red, + ), + ); + return; + } + } + + // 2. 执行确认回调 + onConfirm(inputText); + + // 3. 关闭弹窗 + Navigator.of(context).pop(); + }, + child: Text(confirmText), + ), + ], + ), + ], ), - autofocus: true, // 自动聚焦输入框 ), ), - actions: [ - // 取消按钮 - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(cancelText), - ), - // 确认按钮 - TextButton( - onPressed: () { - // 获取输入文本并去空格 - final String inputText = controller.text.trim(); - - // 1. 执行自定义校验 - if (inputValidator != null) { - final String? errorMsg = inputValidator(inputText); - if (errorMsg != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(errorMsg), - backgroundColor: Colors.red, - ), - ); - return; - } - } - - // 2. 执行确认回调 - onConfirm(inputText); - - // 3. 关闭弹窗 - Navigator.of(context).pop(); - }, - child: Text(confirmText), - ), - ], ); }, ); diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart index eb14ca90..1c454354 100644 --- a/lib/core/consts/http_api_consts.dart +++ b/lib/core/consts/http_api_consts.dart @@ -2,6 +2,9 @@ import '../env/env_config.dart'; class HttpApiConsts { static String get baseUrl => EnvConfig.baseUrl; + static String get saveRegion => '$baseUrl/system/region/save'; + static String get regionsBySite => '$baseUrl/system/region/selectBySiteId'; + static String get deleteRegions => '$baseUrl/system/region/delete'; /// 账号相关 // 登录 @@ -12,7 +15,9 @@ class HttpApiConsts { static String get getUserDevicesList => "$baseUrl/iot/device/list"; // 绑定设备 static String get bindDevice => "$baseUrl/iot/device/bind"; - // 解绑设备 + // 解绑设备(V2 批量接口:body {deviceIds, type},type 取值见 UnbindScope) + static String get unbindDeviceV2 => "$baseUrl/iot/device/unbind"; + // 解绑设备(旧 forward 服接口:单设备,body {deviceId, deviceName}) static String get unbindDevice => "$baseUrl/forward/device/unbind"; // 切换设备 static String get switchDevice => "$baseUrl/forward/device/switchDevice"; @@ -42,6 +47,16 @@ class HttpApiConsts { static String get getDeviceBySpiltCode => "$baseUrl/iot/device/getDeviceBySpiltCode"; + // 一键寻车(GET,query 参数 deviceId = 设备编码 MC700-…,不是数字主键) + static String get hostSearchDevice => "$baseUrl/iot/host/searchDevice"; + + // GET,2=自主任务,3=远程遥控;路径 modeChage 沿用后端协议拼写。 + static String get hostModeChange => "$baseUrl/iot/host/modeChage"; + + // 修改设备别名(只写 deviceAlias,deviceName 保持不变) + static String get updateDeviceAlias => + "$baseUrl/forward/device/updateDeviceAlias"; + // 获取机器人列表 static String get getRobotList => "$baseUrl/iot/device/getSiteList"; diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index e3d86883..23d3d8a0 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,4 +1,9 @@ import 'package:dio/dio.dart'; +import 'adapters/device_control_mode_realtime_adapter.dart'; +import '../../features/device_control_mode/data/datasources/control_mode_remote_datasource.dart'; +import '../../features/device_control_mode/data/repositories/device_control_mode_repository_impl.dart'; +import '../../features/device_control_mode/domain/repositories/device_control_mode_repository.dart'; +import '../../features/device_control_mode/domain/usecases/device_control_mode_usecases.dart'; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -80,6 +85,8 @@ import '../../features/v2/device_list/domain/usecases/get_device_status_data_use as device_v2_usecase; import '../../features/v2/device_list/domain/usecases/get_all_devices_usecase.dart'; import '../../features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart'; +import '../../features/v2/device_list/domain/usecases/update_device_alias_usecase.dart'; +import '../../features/v2/device_list/domain/usecases/search_device_usecase.dart'; import '../../features/v2/device_list/presentation/bloc/device_status_bloc.dart' as device_v2_bloc; import '../../features/v2/device_list/data/datasources/drone_station_datasource.dart'; @@ -101,6 +108,9 @@ import '../../features/v2/device_list/data/repositories/bind_device_repository_i import '../../features/v2/device_list/domain/repositories/bind_device_repository.dart'; import '../../features/v2/device_list/domain/usecases/bind_device_usecases.dart'; import '../../features/v2/device_list/presentation/cubit/bind_device_cubit.dart'; +import '../../features/v2/device_list/presentation/cubit/device_ownership_action_cubit.dart'; +import '../../features/v2/device_list/presentation/cubit/rename_device_cubit.dart'; +import '../../features/v2/device_list/presentation/cubit/find_robot_cubit.dart'; import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart'; import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart'; import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart'; @@ -139,6 +149,15 @@ import '../../features/devices/domain/repositories/device_task_repository.dart'; import '../../features/devices/domain/usecases/get_device_task_pool_usecase.dart'; import '../../features/devices/domain/usecases/cancel_task_usecase.dart'; import '../../features/devices/domain/usecases/pause_task_usecase.dart'; +import '../../features/devices/domain/usecases/save_device_schedule_plan_usecase.dart'; +import '../../features/v2/work/presentation/cubit/schedule_plan_create_cubit.dart'; +import '../../features/v2/work/presentation/cubit/work_home_task_cubit.dart'; +import '../../features/regions/data/datasources/region_remote_datasource.dart'; +import '../../features/regions/data/repositories/region_repository_impl.dart'; +import '../../features/regions/domain/repositories/region_repository.dart'; +import '../../features/regions/domain/usecases/region_usecases.dart'; +import '../../features/regions/presentation/cubit/region_editor_cubit.dart'; +import '../../features/regions/presentation/cubit/region_list_cubit.dart'; import '../../features/devices/domain/usecases/recovery_task_usecase.dart'; import '../../features/devices/presentation/bloc/device_task_cubit.dart'; import '../../features/autonomous_work/data/gateways/autonomous_work_gateway_factory.dart'; @@ -386,6 +405,15 @@ Future init() async { sl.registerLazySingleton( () => GetDeviceBySpiltCodeUseCase(repository: sl()), ); + sl.registerLazySingleton( + () => UpdateDeviceAliasUseCase(repository: sl()), + ); + sl.registerFactory(() => RenameDeviceCubit(sl())); + // 一键寻车:UI → FindRobotCubit → SearchDeviceUseCase → DeviceRepository → DataSource + sl.registerLazySingleton( + () => SearchDeviceUseCase(repository: sl()), + ); + sl.registerFactory(() => FindRobotCubit(sl())); sl.registerFactory( () => device_v2_bloc.DeviceStatusBloc(sl()), ); @@ -611,9 +639,15 @@ Future init() async { () => DeviceTaskRepositoryImpl(datasource: sl()), ); sl.registerLazySingleton(() => GetDeviceTaskPoolUseCase(sl())); + sl.registerFactory(() => WorkHomeTaskCubit(sl())); sl.registerLazySingleton(() => CancelTaskUseCase(sl())); sl.registerLazySingleton(() => PauseTaskUseCase(sl())); sl.registerLazySingleton(() => RecoveryTaskUseCase(sl())); + // 创建定时作业计划(作业计划页·新建 → POST /iot/deviceTask/save) + sl.registerLazySingleton(() => SaveDeviceSchedulePlanUseCase(sl())); + sl.registerFactory( + () => SchedulePlanCreateCubit(sl()), + ); sl.registerLazySingleton(() => DeviceTaskCubit(sl(), sl(), sl(), sl())); /// 10.1 自主作业业务门面(一套功能多套环境实现) @@ -622,6 +656,48 @@ Future init() async { sl.registerLazySingleton( () => AutonomousWorkGatewayFactory.current(), ); + sl.registerLazySingleton( + () => DeviceControlModeRealtimeAdapter(sl()), + ); + sl.registerLazySingleton( + () => ControlModeRemoteDatasource(sl(), sl()), + ); + sl.registerLazySingleton( + () => DeviceControlModeRepositoryImpl( + realtime: sl().watch, + remote: sl(), + ), + ); + sl.registerLazySingleton( + () => WatchDeviceControlModeUseCase(sl()), + ); + sl.registerLazySingleton( + () => ChangeDeviceControlModeUseCase(sl()), + ); + sl.registerLazySingleton( + () => RegionRemoteDatasource(sl(), sl()), + ); + sl.registerLazySingleton( + () => RegionRepositoryImpl( + remote: sl(), + routes: sl(), + ), + ); + sl.registerLazySingleton(() => SaveRegionUseCase(sl())); + sl.registerLazySingleton( + () => LoadRegionRoutesUseCase(sl()), + ); + sl.registerLazySingleton(() => LoadRegionsUseCase(sl())); + sl.registerLazySingleton(() => DeleteRegionsUseCase(sl())); + sl.registerFactory( + () => RegionListCubit(sl(), sl()), + ); + sl.registerFactory( + () => RegionEditorCubit( + sl(), + sl(), + ), + ); /// 11. 工单管理 (Work Order) sl.registerLazySingleton( @@ -698,7 +774,20 @@ Future init() async { sl.registerLazySingleton( () => IsDeviceAtSiteUseCase(sl()), ); + sl.registerLazySingleton( + () => UnbindDeviceV2UseCase(sl()), + ); sl.registerFactory( () => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl()), ); + sl.registerFactory( + () => DeviceOwnershipActionCubit( + sl(), // GetOrgListUseCase + sl(), // GetSitesByOrgUseCase + sl(), // GetUsersBySiteUseCase + sl(), // BindDeviceV2UseCase(转交) + sl(), // UnbindDeviceV2UseCase(解绑) + sl(), + ), + ); } diff --git a/lib/features/auth/presentation/pages/login_page_v2.dart b/lib/features/auth/presentation/pages/login_page_v2.dart index b728c4fd..2f98603e 100644 --- a/lib/features/auth/presentation/pages/login_page_v2.dart +++ b/lib/features/auth/presentation/pages/login_page_v2.dart @@ -381,11 +381,11 @@ class _LoginPageV2State extends State const SizedBox(height: 12), _buildProtocolSection(), const SizedBox(height: 4), - const Padding( - padding: EdgeInsets.only(left: 32), + Padding( + padding: const EdgeInsets.only(left: 32), child: Text( - '新账户注册前须主动勾选确认', - style: TextStyle(fontSize: 12, color: _grayText), + AppLocalizations.of(context).translate('register.agree_hint'), + style: const TextStyle(fontSize: 12, color: _grayText), ), ), const SizedBox(height: 14), diff --git a/lib/features/autonomous_work/data/gateways/local_demo_work_gateway.dart b/lib/features/autonomous_work/data/gateways/local_demo_work_gateway.dart index 8ca02343..471623fd 100644 --- a/lib/features/autonomous_work/data/gateways/local_demo_work_gateway.dart +++ b/lib/features/autonomous_work/data/gateways/local_demo_work_gateway.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:latlong2/latlong.dart'; import '../../domain/entities/work_session_models.dart'; +import '../../domain/entities/return_plan_models.dart'; import '../../domain/repositories/autonomous_work_gateway.dart'; /// B 环境示例实现:完全本地内存,不依赖任何后端。 @@ -105,6 +106,8 @@ class LocalDemoWorkGateway implements AutonomousWorkGateway { required List path, required List outer, required int planModel, + // 本地演示无后端校验,只为与线上接口保持同一份签名 + required String routeType, String? imageBase64, }) async { _savedRoutes.add( @@ -120,6 +123,21 @@ class LocalDemoWorkGateway implements AutonomousWorkGateway { return const WorkResult.ok(true); } + @override + Future> saveReturnPlan(ReturnPlanDraft draft) async { + // B 环境演示:无后端,仅校验后本地确认,返回一个假主键占位 + if (draft.isEmpty) return const WorkResult.fail('没有可保存的返航点或障碍'); + return WorkResult.ok(++_routeSeq); + } + + @override + Future> fetchReturnPlanBySiteId({ + required int siteId, + }) async { + // 本地演示:无后端,始终返回「暂无方案」→ 进入空白新建态 + return WorkResult.ok(null); + } + @override Future>> loadTasks({ required String deviceId, diff --git a/lib/features/devices/data/datasources/device_task_datasource.dart b/lib/features/devices/data/datasources/device_task_datasource.dart index 33cc4b58..7a0bcaa4 100644 --- a/lib/features/devices/data/datasources/device_task_datasource.dart +++ b/lib/features/devices/data/datasources/device_task_datasource.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import '../../../../core/consts/http_api_consts.dart'; import '../../../../core/logging/i_logger_service.dart'; +import '../../domain/entities/device_schedule_plan_entity.dart'; import '../models/device_task_model.dart'; abstract class DeviceTaskDatasource { @@ -35,6 +36,10 @@ abstract class DeviceTaskDatasource { required int orgId, required int siteId, }); + + /// 创建设备定时任务(定时作业计划)POST /iot/deviceTask/save + /// 周期规则报文组装封在实现内,调用方只交业务实体 + Future saveDeviceSchedulePlan({required DeviceSchedulePlanEntity plan}); } class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { @@ -53,8 +58,8 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { }) async { try { final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/deviceTaskPool'; - _logger.logWithLevel('[getDeviceTaskPool] 请求: POST $url'); - _logger.logWithLevel('[getDeviceTaskPool] 参数: userId=$userId, siteId=$siteId, orgId=$orgId'); + _logger.logWithLevel('[getDeviceTaskPool] 请求: POST $url', shouldLog: true); + _logger.logWithLevel('[getDeviceTaskPool] 参数: userId=$userId, siteId=$siteId, orgId=$orgId, pageNum=$pageNum, pageSize=$pageSize', shouldLog: true); final response = await dio.post( url, data: { @@ -70,6 +75,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { final data = response.data as Map; if (data['code'] == 200) { final rows = data['rows'] as List? ?? []; + _logger.logWithLevel('[getDeviceTaskPool] 返回任务 ${rows.length} 条', shouldLog: true); return rows .map((item) => DeviceTaskModel.fromJson(item as Map)) .toList(); @@ -199,4 +205,84 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { rethrow; } } + + @override + Future saveDeviceSchedulePlan({ + required DeviceSchedulePlanEntity plan, + }) async { + final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/save'; + final body = _schedulePlanBody(plan); + _logger.logWithLevel('[saveDeviceSchedulePlan] 请求: POST $url', shouldLog: true); + _logger.logWithLevel('[saveDeviceSchedulePlan] 参数: ${jsonEncode(body)}', shouldLog: true); + try { + final response = await dio.post(url, data: body); + _logger.logWithLevel('[saveDeviceSchedulePlan] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}', shouldLog: true); + + if (response.statusCode == 200) { + final data = response.data as Map; + if (data['code'] == 200) { + // 保存接口返回数量(如 data: 1),同时兼容布尔值及无 data 的成功响应。 + final saved = switch (data['data']) { + bool value => value, + num value => value > 0, + _ => true, + }; + _logger.logWithLevel( + '[saveDeviceSchedulePlan] 结果: ${saved ? '成功' : '未保存'}', + shouldLog: true, + ); + return saved; + } else { + _logger.logWithLevel('[saveDeviceSchedulePlan] 业务失败: code=${data['code']}, msg=${data['msg']}', shouldLog: true); + throw Exception(data['msg'] ?? '创建定时计划失败'); + } + } else { + throw Exception('HTTP ${response.statusCode}'); + } + } catch (e) { + _logger.logWithLevel('[saveDeviceSchedulePlan] 异常: $e', shouldLog: true); + rethrow; + } + } + + /// 业务实体 → 后端报文:字段名 / DAY・WEEK・MONTH 枚举字面量 / + /// days・dates 条件结构 / 时间格式,全部封在数据层,不外泄到 domain。 + static Map _schedulePlanBody(DeviceSchedulePlanEntity p) { + // 下标 = weekday.value - 1(周一~周日) + const List weekdayWire = [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]; + String pad(int value) => value.toString().padLeft(2, '0'); + String dateText(DateTime d) => + '${d.year}-${pad(d.month)}-${pad(d.day)}'; + + return { + 'deviceId': p.deviceId, + 'orgId': p.orgId, + 'planName': p.planName.trim(), + 'routeId': p.routeId, + 'siteId': p.siteId, + 'deviceTaskPlanRules': { + 'type': switch (p.type) { + WorkScheduleType.day => 'DAY', + WorkScheduleType.week => 'WEEK', + WorkScheduleType.month => 'MONTH', + }, + if (p.type == WorkScheduleType.week) + 'days': [ + for (final Weekday w in p.days) weekdayWire[w.value - 1], + ], + if (p.type == WorkScheduleType.month) 'dates': p.dates, + 'dayTime': '${pad(p.dayHour)}:${pad(p.dayMinute)}:00', + }, + 'startTime': '${dateText(p.startDate)} 00:00:00', + 'finishTime': '${dateText(p.endDate)} 23:59:59', + }; + } } diff --git a/lib/features/devices/data/models/device_task_model.dart b/lib/features/devices/data/models/device_task_model.dart index 3733aad9..aa72e47e 100644 --- a/lib/features/devices/data/models/device_task_model.dart +++ b/lib/features/devices/data/models/device_task_model.dart @@ -16,8 +16,11 @@ class DeviceTaskModel extends DeviceTaskEntity { return DeviceTaskModel( id: json['id'] as int, deviceId: json['deviceId'] as String? ?? '', - taskStatus: json['taskStaus'] as String? ?? '', - taskStatusTranslate: json['taskStausTranslate'] as String? ?? '', + taskStatus: (json['taskStaus'] ?? json['taskStatus']) as String? ?? '', + taskStatusTranslate: + (json['taskStausTranslate'] ?? json['taskStatusTranslate']) + as String? ?? + '', routeId: json['routeId'] as int?, siteId: json['siteId'] as int?, orgId: json['orgId'] as int?, diff --git a/lib/features/devices/data/repositories/device_task_repository_impl.dart b/lib/features/devices/data/repositories/device_task_repository_impl.dart index 10c4f076..4fea0314 100644 --- a/lib/features/devices/data/repositories/device_task_repository_impl.dart +++ b/lib/features/devices/data/repositories/device_task_repository_impl.dart @@ -1,5 +1,6 @@ import 'package:fpdart/fpdart.dart'; import '../../../../core/error/failure.dart'; +import '../../domain/entities/device_schedule_plan_entity.dart'; import '../../domain/entities/device_task_entity.dart'; import '../../domain/repositories/device_task_repository.dart'; import '../datasources/device_task_datasource.dart'; @@ -92,4 +93,16 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository { return Left(Failure(e.toString())); } } + + @override + Future> saveDeviceSchedulePlan({ + required DeviceSchedulePlanEntity plan, + }) async { + try { + final result = await datasource.saveDeviceSchedulePlan(plan: plan); + return Right(result); + } catch (e) { + return Left(Failure(e.toString())); + } + } } diff --git a/lib/features/devices/domain/entities/device_schedule_plan_entity.dart b/lib/features/devices/domain/entities/device_schedule_plan_entity.dart new file mode 100644 index 00000000..d9e59c61 --- /dev/null +++ b/lib/features/devices/domain/entities/device_schedule_plan_entity.dart @@ -0,0 +1,143 @@ +/// 定时作业计划(设备定时任务)业务实体。 +/// +/// 只表达业务概念(设备/路线/周期规则/生效区间),不含任何后端字段名与 +/// 报文格式;entity → JSON 的组装封在 data 层(DeviceTaskDatasource)。 +/// 对应接口:POST /iot/deviceTask/save(按天/周/月循环策略)。 +library; + +/// 循环策略:每天 / 每周指定星期 / 每月指定几号 +enum WorkScheduleType { day, week, month } + +/// 星期几(domain 自定义,不绑定 intl;value 与 DateTime.weekday 同口径 1-7) +enum Weekday { monday, tuesday, wednesday, thursday, friday, saturday, sunday } + +extension WeekdayX on Weekday { + /// 1-7,周一=1,与 [DateTime.weekday] 对齐 + int get value => index + 1; +} + +/// 星期选择项(周一~周日),提交时由数据层映射为后端枚举字面量 +const List kWorkScheduleWeekdays = [ + Weekday.monday, + Weekday.tuesday, + Weekday.wednesday, + Weekday.thursday, + Weekday.friday, + Weekday.saturday, + Weekday.sunday, +]; + +/// 一条待创建的定时作业计划。 +/// +/// 时间拆成「日期 + 时分」: +/// - [startDate] / [endDate]:方案生效区间(仅日期部分有效, +/// 提交时格式化为开始日 00:00:00 ~ 结束日 23:59:59) +/// - [dayHour] / [dayMinute]:每日执行时刻 +class DeviceSchedulePlanEntity { + const DeviceSchedulePlanEntity({ + required this.deviceId, + required this.orgId, + required this.planName, + required this.routeId, + required this.siteId, + required this.type, + this.days = const [], + this.dates = const [], + this.dayHour = 8, + this.dayMinute = 0, + required this.startDate, + required this.endDate, + }); + + /// 设备 SN(如 MC700-xxx,与任务下发接口同一口径) + final String deviceId; + + /// 所属组织 ID(与场站同源,取自 SiteCubit 选中场站的 orgId) + final int orgId; + + /// 定时任务方案名称 + final String planName; + + /// 作业路线 ID(路线库一条作业记录的主键,A 环境 = workRecord.id) + final int routeId; + + /// 站点 ID(当前选中场站) + final int siteId; + + final WorkScheduleType type; + + /// type=week:选中星期集合(不能为空) + final List days; + + /// type=month:每月执行日期集合,取值 1-31(不能为空;小月缺该日则当月跳过) + final List dates; + + /// 每日执行时刻 + final int dayHour; + final int dayMinute; + + /// 生效区间(仅日期部分有效) + final DateTime startDate; + final DateTime endDate; + + /// 提交前的完整校验,返回 null = 通过。 + /// + /// 与后端入参校验规则逐条对齐,本地能拦的先拦(省一次失败往返): + /// 1. 必填项(设备/名称/路线/场站/组织); + /// 2. days / dates 数组非空(按周期类型); + /// 3. 结束时间必须晚于开始时间; + /// 4. 业务强校验:生效区间内必须存在至少一个满足周期规则、 + /// 且执行时刻落在区间内的可执行日期;否则后端拒绝保存。 + String? getValidationError() { + if (deviceId.isEmpty) return '请选择执行设备'; + if (planName.trim().isEmpty) return '请填写方案名称'; + if (routeId <= 0) return '请选择作业路线'; + if (siteId <= 0 || orgId <= 0) return '请先在首页选择场站'; + if (type == WorkScheduleType.week && days.isEmpty) { + return '按周执行需至少选择一个星期'; + } + if (type == WorkScheduleType.month && dates.isEmpty) { + return '按月执行需至少选择一个日期'; + } + if (dates.any((d) => d < 1 || d > 31)) { + return '每月执行日期取值范围为 1-31'; + } + if (dayHour < 0 || dayHour > 23 || dayMinute < 0 || dayMinute > 59) { + return '执行时刻不合法'; + } + if (_dayOf(endDate).isBefore(_dayOf(startDate))) { + return '结束日期必须晚于开始日期'; + } + if (!hasExecutableTime) { + return '生效区间内没有满足周期规则的可执行日期,任务不会触发'; + } + return null; + } + + /// 业务强校验:逐天扫描 [startDate, endDate], + /// 存在「命中周期规则 且 执行时刻落在 [开始日 00:00, 结束日 24:00) 内」的一天即可执行。 + /// 示例:有效期 11-19 ~ 11-20、week · days=[monday],区间内没有周一 → false。 + bool get hasExecutableTime { + DateTime day = _dayOf(startDate); + final DateTime last = _dayOf(endDate); + while (!day.isAfter(last)) { + if (_matches(day)) { + // startTime 固定当天 00:00:00、finishTime 当天 23:59:59, + // 任意时刻的执行点在区间内等价于「日期落在 [start, end] 闭区间」 + return true; + } + day = day.add(const Duration(days: 1)); + } + return false; + } + + bool _matches(DateTime day) => switch (type) { + WorkScheduleType.day => true, + WorkScheduleType.week => days.contains( + kWorkScheduleWeekdays[day.weekday - 1], + ), + WorkScheduleType.month => dates.contains(day.day), + }; + + static DateTime _dayOf(DateTime d) => DateTime(d.year, d.month, d.day); +} diff --git a/lib/features/devices/domain/repositories/device_task_repository.dart b/lib/features/devices/domain/repositories/device_task_repository.dart index 802846c2..10474099 100644 --- a/lib/features/devices/domain/repositories/device_task_repository.dart +++ b/lib/features/devices/domain/repositories/device_task_repository.dart @@ -1,5 +1,6 @@ import 'package:fpdart/fpdart.dart'; import '../../../../core/error/failure.dart'; +import '../entities/device_schedule_plan_entity.dart'; import '../entities/device_task_entity.dart'; abstract class DeviceTaskRepository { @@ -36,4 +37,9 @@ abstract class DeviceTaskRepository { required int orgId, required int siteId, }); + + /// 创建设备定时任务(定时作业计划,按天/周/月循环) + Future> saveDeviceSchedulePlan({ + required DeviceSchedulePlanEntity plan, + }); } diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart index db80b364..cd58b281 100644 --- a/lib/features/devices/presentation/bloc/device_status_bloc.dart +++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart @@ -57,6 +57,9 @@ class DeviceStatusBloc extends Bloc { // 🔥 当前 MQTT 机器状态监听的设备 SN(用于换设备时清空旧缓存) String? _currentListeningSn; + /// 当前实时快照的设备身份,供入口模式确认使用(只读)。 + String? get listeningDeviceSn => _currentListeningSn; + // 🔥 MQTT机器状态收帧计数(用于链路日志) int _mqttVehicleFrameCount = 0; int _mqttLocationFrameCount = 0; @@ -322,8 +325,12 @@ class DeviceStatusBloc extends Bloc { rightTargetSpeed: host.rightTargetRpm, leftMeasureSpeed: host.leftMeasuredRpm, rightMeasureSpeed: host.rightMeasuredRpm, - controlMode: - host.controlMode.isNotEmpty ? host.controlMode : base.controlMode, + // 严格按后端本帧给的值写:没带 control_mode 就置空(显示「未知」)。 + // 以前写的是 host.controlMode.isNotEmpty ? ... : base.controlMode, + // 导致“后端已经不发控制模式了”界面仍永远停在第一次收到的值上。 + // 代价:若 host 帧偶发漏带该字段,此栏会在真值与「未知」间跳动; + // 现场若出现闪烁,说明 host 帧字段不稳定,应从后端侧解决而非在此兜底。 + controlMode: host.controlMode, workingArea: host.totalWorkArea.toString(), // 🔥 供全功能模式(pv_control)展示:累计作业里程 + host 已推数据标记 totalWorkDistance: host.totalWorkDistance, @@ -486,7 +493,11 @@ class DeviceStatusBloc extends Bloc { if (_cachedStatus != null && !isClosed) { final gps = _cachedGps ?? GPSEntity(_cachedStatus!.latitude, _cachedStatus!.longitude); - debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${gps.latitude}, ${gps.longitude}) yaw=${_cachedStatus!.yaw} 控制模式=${_cachedStatus!.controlMode} | ${DateTime.now().toString().substring(11, 19)}'); + // 控制模式为空串 = 后端从未给过,日志里显式标出来,免得和 0 混看 + final controlModeLog = _cachedStatus!.controlMode.isEmpty + ? '--(未收到)' + : _cachedStatus!.controlMode; + debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${gps.latitude}, ${gps.longitude}) yaw=${_cachedStatus!.yaw} 控制模式=$controlModeLog | ${DateTime.now().toString().substring(11, 19)}'); emit(DeviceStatusUpdated(_cachedStatus!, gps)); } } diff --git a/lib/features/home/presentation/pages/dj_page.dart b/lib/features/home/presentation/pages/dj_page.dart index 269de9fe..0ee05550 100644 --- a/lib/features/home/presentation/pages/dj_page.dart +++ b/lib/features/home/presentation/pages/dj_page.dart @@ -6,7 +6,13 @@ import 'package:maibu_satabot_v2/core/router/route_paths.dart'; import 'package:webview_flutter/webview_flutter.dart'; class DjPage extends StatefulWidget { - const DjPage({super.key}); + /// 从横屏页(如「地图与区域编辑」)push 进来时为 true: + /// 退出统一用 pop 回来源页。若用 context.go() 整栈替换, + /// 来源页 await push 的 Future 永不完成,横屏恢复不会执行, + /// 页面会被锁死在竖屏。首页进入时保持原 go() 行为。 + final bool exitToPop; + + const DjPage({super.key, this.exitToPop = false}); @override State createState() => _DjPageState(); @@ -41,6 +47,11 @@ class _DjPageState extends State { final page = data["page"]; if (action == "navigate") { + // 编辑器入口:H5 发出任何跳转都视为「场站已选好」,pop 回横屏编辑页 + if (widget.exitToPop) { + if (context.canPop()) context.pop(); + return; + } if (page == "gaode") { _openGaode(); } @@ -66,7 +77,12 @@ class _DjPageState extends State { leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () { - context.go(RoutePaths.home); + // 编辑器入口:pop 回来源页(触发对方 await 完成→恢复横屏) + if (widget.exitToPop && context.canPop()) { + context.pop(); + } else { + context.go(RoutePaths.home); + } }, ), title: Text(AppLocalizations.of(context).translate('route_planning.title'), style: const TextStyle(color: Colors.white)), diff --git a/lib/features/home/presentation/routes/home_routes.dart b/lib/features/home/presentation/routes/home_routes.dart index 2624ba0f..627b2e52 100644 --- a/lib/features/home/presentation/routes/home_routes.dart +++ b/lib/features/home/presentation/routes/home_routes.dart @@ -20,14 +20,29 @@ class HomeRoutes { static List get routes => [ GoRoute( path: RoutePaths.remoteControl, - builder: (context, state) => BlocProvider( - // 每次进入该路由,都会创建一个全新的 Cubit 并开启循环 - create: (context) => sl()..startControlLoop(), + // 🔥 必须用 .value:RemoteControlCubit 是 GetIt 的 registerLazySingleton + // (injection.dart:540),不是本路由创建的。BlocProvider(create:) 的所有权 + // 语义是“我创建我销毁”,离开路由时它会对这个实例调用 close(),等于把 + // 全局单例关掉: + // · 关掉后 startControlLoop 的 isClosed 守卫直接 return、所有 emit 被忽略, + // 旧遥控页与新版 pv_control HUD 一起静默失灵(推杆不动 / 权限不刷新 / + // 弹窗不弹),而 LazySingleton 不会重建,只能杀进程恢复; + // · 护栏加之前更狠:每个迟到回调都抛 StateError(“Cannot emit new states + // after calling close”)。 + // 下方 DeviceStatusBloc 用的就是 .value(“使用全局单例,确保数据流不中断”), + // 遥控保持一致。原来的注释“每次进入都会创建一个全新的 Cubit”是错的。 + builder: (context, state) => BlocProvider.value( + value: sl(), child: const RemoteControlPage(), ), ), GoRoute(path: RoutePaths.routePlan, builder: (context, state) => const RoutePlanPage()), - GoRoute(path: RoutePaths.djMap, builder: (context, state) => const DjPage()), + // 场站 H5:从横屏「地图与区域编辑」进入时传 extra=true,退出用 pop 回来源页; + // 从首页进入保持原来 go() 换栈行为不变。 + GoRoute( + path: RoutePaths.djMap, + builder: (context, state) => DjPage(exitToPop: state.extra == true), + ), GoRoute(path: RoutePaths.productDesc, builder: (context, state) => const ProductPage()), GoRoute(path: RoutePaths.usage, builder: (context, state) => const ManualPage()), GoRoute( diff --git a/lib/features/home/presentation/widgets/common/enum.dart b/lib/features/home/presentation/widgets/common/enum.dart index bb2340fb..0a203257 100644 --- a/lib/features/home/presentation/widgets/common/enum.dart +++ b/lib/features/home/presentation/widgets/common/enum.dart @@ -19,3 +19,17 @@ enum WorkStatus { working, // 作业中 paused, // 暂停中 } + +/// 后端 workRecord.routeType(Java 枚举 RouteType):规划类型,保存打点时必填。 +/// 缺失时后端返回「未选择规划类型」;code 必须与后端枚举 name() 完全一致。 +/// 新版「地图与区域编辑」与旧版路径规划共用 /iot/workRecord/add,因此共用这一个定义。 +enum RouteType { + mowingTask('MOWING_TASK', '割草任务'), + inspectionTask('INSPECTION_TASK', '巡检任务'), + // backToRestPoint('BACK_TO_REST_POINT', '返航任务'), // 暂不展示 + other('OTHER', '其他类型'); + + final String code; + final String label; + const RouteType(this.code, this.label); +} diff --git a/lib/features/machine_details/presentation/pages/machine_details_page.dart b/lib/features/machine_details/presentation/pages/machine_details_page.dart index 7cf353e3..c009c1c2 100644 --- a/lib/features/machine_details/presentation/pages/machine_details_page.dart +++ b/lib/features/machine_details/presentation/pages/machine_details_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/components/control_mode_text.dart'; import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart'; @@ -286,9 +287,12 @@ class _MachineDetailsPageState extends State { if (blocState is DeviceStatusUpdated) { debugPrint('✅ [MachineDetails] 有实时数据 - 电压:${blocState.status.voltage}, 电量:${blocState.status.battery}'); - controlMode = blocState.status.controlMode == '3' - ? AppLocalizations.of(context).translate('machine_details.remote_mode') - : AppLocalizations.of(context).translate('machine_details.local_mode'); + // 统一映射(与「设备数据监控」同一口径):后端没给控制模式时显示「未知」, + // 不能再一律兜成「本地模式」(协议:0=未知 1=本地 2=自主任务 3=远程遥控) + controlMode = controlModeDisplayText( + AppLocalizations.of(context), + blocState.status.controlMode, + ); voltage = '${blocState.status.voltage}V'; battery = '${blocState.status.battery}%'; } else { diff --git a/lib/features/main_container/presentation/main_wrapper.dart b/lib/features/main_container/presentation/main_wrapper.dart index 54836776..20cf12f8 100644 --- a/lib/features/main_container/presentation/main_wrapper.dart +++ b/lib/features/main_container/presentation/main_wrapper.dart @@ -111,6 +111,7 @@ class _MainWrapperState extends State { // 作业首页(W01):返回键切回「设备」tab case 'work': return WorkHomePageV2( + isActive: enabledItems[_currentIndex].id == tab.id, onBack: () { final int deviceIndex = enabledItems.indexWhere( (item) => item.id == 'device', diff --git a/lib/features/pv_control/presentation/models/pv_telemetry.dart b/lib/features/pv_control/presentation/models/pv_telemetry.dart index 08f59781..b90652b1 100644 --- a/lib/features/pv_control/presentation/models/pv_telemetry.dart +++ b/lib/features/pv_control/presentation/models/pv_telemetry.dart @@ -6,12 +6,15 @@ import 'package:flutter/foundation.dart'; @immutable class PvTelemetry { const PvTelemetry({ - this.deviceName = '光伏巡检机器人', + // 设备名占位留空:实际名称由页面注入(见 PvControlPage._resolveTelemetry), + // 避免写死中文在英文环境下露出来 + this.deviceName = '', this.serialNumber = 'PV-RB-001', this.isOnline = true, this.battery, this.isAutoDriving = true, - this.taskStatus = '巡检任务进行中', + // 默认留空:具体文案由 UI 层按当前语言兜底(见 PvInfoColumn) + this.taskStatus = '', this.speed = 0.3, this.mileage, this.networkNormal = true, diff --git a/lib/features/pv_control/presentation/pages/pv_control_page.dart b/lib/features/pv_control/presentation/pages/pv_control_page.dart index 535038de..ff073697 100644 --- a/lib/features/pv_control/presentation/pages/pv_control_page.dart +++ b/lib/features/pv_control/presentation/pages/pv_control_page.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart'; +import 'package:maibu_satabot_v2/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart'; import 'dart:io'; import 'dart:ui' as ui; @@ -21,6 +23,7 @@ import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remot import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart'; import 'package:maibu_satabot_v2/features/v2/usb_camera/data/uvc_camera_service.dart'; import 'package:maibu_satabot_v2/features/v2/usb_camera/presentation/cubit/usb_camera_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/device_list/domain/usecases/search_device_usecase.dart'; import '../models/pv_telemetry.dart'; import '../channel/pv_robot_command_channel.dart'; @@ -48,6 +51,7 @@ class PvControlPage extends StatefulWidget { required this.deviceId, this.deviceName, this.initialMode = PvViewMode.normal, + this.initialShowJoysticks = true, }); final String deviceId; @@ -57,6 +61,11 @@ class PvControlPage extends StatefulWidget { /// [PvViewMode.manualControl],进页直接是手动控制 HUD(不再经过大屏)。 final PvViewMode initialMode; + /// 进页时是否显示全屏 HUD 的两侧方向摇杆(默认显示)。 + /// 从「进入实时视频」那条链路(先看画面再转遥控)进来时传 false, + /// 摇杆默认收起,用户在设置里需要时再手动打开。 + final bool initialShowJoysticks; + @override State createState() => _PvControlPageState(); } @@ -75,6 +84,15 @@ class _PvControlPageState extends State { /// 控制态是否已激活:进入「手动控制」才 activate,退出 / 页面销毁 deactivate。 bool _controlActive = false; + /// deactivate 是否正在进行中(内部含 await 归零全停 + HTTP 释放,约 0.5~1s)。 + /// 🔥 这段时间里若用户立刻重新点「手动控制」,不能马上 activate: + /// 尾部那次 stopControlLoop() 会把刚起来的循环又关掉,HUD 就变成 + /// “摇杆推了没反应”。故延后到 deactivate 收尾再起循环。 + bool _deactivating = false; + + /// deactivate 期间收到过重新进入控制态的请求。 + bool _restartAfterDeactivate = false; + /// 急停开关态:HUD 急停钮每点一次翻转,经通道下发 updateEmergency。 bool _emergencyOn = false; @@ -107,12 +125,32 @@ class _PvControlPageState extends State { /// 模拟开关(真实推送未接入前在设置面板手动触发)。 bool _simObstacle = false; - bool _simReverse = false; + + /// 「倒车影像」:控制手动控制主画面中间那组导向路径 + 黄色/绿色车道线 + /// (带流动箭头)的显隐,同时决定左下后视卡是否叠倒车引导线。 + /// 默认开启 = 保持原有视觉;关掉后画面回到干净视频。 + bool _simReverse = true; + + /// 是否显示手动控制 HUD 的左右两个方向摇杆(设置面板可关掉): + /// 关掉后摇杆与回中按钮一起隐藏,底部操作条自动向两侧展开。 + /// 初始值由入口决定([PvControlPage.initialShowJoysticks])。 + late bool _showJoysticks = widget.initialShowJoysticks; + + /// 当前控制设备名:优先用页面入参(就是入口点选的那台,带别名), + /// 缺失时回退 RemoteControlCubit 的 targetDevice,都没有则显示占位。 + String _resolveDeviceName(RemoteControlState? remote) { + final passed = (widget.deviceName ?? '').trim(); + if (passed.isNotEmpty) return passed; + final target = (remote?.targetDevice?.deviceName ?? '').trim(); + return target.isNotEmpty + ? target + : AppLocalizations.of(context).translate('pv_control.no_device'); + } /// 实际下发的遥测:真实字段来自全局 [DeviceStatusBloc] 的 host 实时数据 /// (电量/巡检里程/设备温度),未收到时置 null 由 UI 显示"未知"; /// 开启「模拟障碍物信息」相当于收到机器告警推送,叠加 obstacleAlert。 - PvTelemetry _resolveTelemetry(DeviceStatusState dsState) { + PvTelemetry _resolveTelemetry(DeviceStatusState dsState, String deviceName) { double? battery; double? mileage; double? temp; @@ -135,6 +173,8 @@ class _PvControlPageState extends State { } } return _telemetry.copyWith( + // 设备名用真实选中的设备,不再用 PvTelemetry 里写死的占位名 + deviceName: deviceName, obstacleAlert: _simObstacle, battery: battery, mileage: mileage, @@ -155,8 +195,7 @@ class _PvControlPageState extends State { sl().enterSafeMode(); // 监听「权限交接」:其它端请求控制权时弹确认框,整页生命周期常驻。 _initPermissionHandoverListener(); - // 进页面即建立控制会话:查询权限 + 启动控制循环(等价远程遥控页进页行为)。 - WidgetsBinding.instance.addPostFrameCallback((_) => _startControl()); + // 控制会话由模式确认完成后的回调建立。 } /// 本页的视频、地图、遥测都是围绕 `deviceId` 这一台设备,所以进页时确保 @@ -219,12 +258,51 @@ class _PvControlPageState extends State { void _onManualControl() { setState(() => _mode = PvViewMode.manualControl); + // 🔥 折叠会退出控制态(见 _onCollapseManual),重新进 HUD 必须重新进入: + // _startControl 有 _controlActive 幂等保护,首次进页(initState)重复调也不会起两份循环。 + _startControl(); } void _onCollapseManual() { + // 🔥 折叠 = 退出控制态,不只是一次归零:大屏上没有摇杆,却留着 + // activate() 起着的 200ms 循环与控制权,既持续向设备发零速帧、又挤占 + // 同一条 TCP 上的业务指令;而 HUD 被卸载那一瞬,摇杆的 onPanEnd / + // Listener.onPointerUp 三重兜底会随 State 一起消失,若另一只手仍按着 + // 摇杆,轴值就残留在非零 —— 大屏上没东西可归零,相当于“一直走”。 + // deactivate() 内部顺序已是:解除急停 → await 归零+全停 → 停循环 → 释放权限。 + // 不弹 toast:折叠是常规视图切换,不是“退出遥控”,每次弹释放提示很噪。 + _stopControl(notify: false); setState(() => _mode = PvViewMode.normal); } + /// 一键寻车:视频区 person_search 按钮,直连后端 searchDevice 接口。 + /// + /// deviceId 用 widget.deviceId(即设备编码 deviceName,MC700- 开头), + /// 不是 getSiteList 的数字主键;下发结果以胶囊 Toast 反馈。 + Future _onLocate() async { + final code = widget.deviceId.trim(); + final tr = AppLocalizations.of(context); + if (code.isEmpty) { + CapsuleToast.show( + tr.translate('device_list_v2.fr_toast_start_failed'), + showCheck: false, + ); + return; + } + final result = await sl().execute(deviceCode: code); + if (!mounted) return; + result.fold( + (failure) => CapsuleToast.show( + failure.message.isEmpty + ? tr.translate('device_list_v2.fr_toast_start_failed') + : failure.message, + showCheck: false, + ), + (_) => + CapsuleToast.show(tr.translate('device_list_v2.fr_toast_start_sent')), + ); + } + void _onPauseTask() { CapsuleToast.show( AppLocalizations.of(context).translate('pv_control.toast_paused'), @@ -248,10 +326,17 @@ class _PvControlPageState extends State { /// 进入控制态:查询权限 + 启动控制循环(与远程遥控页生命周期一致)。 void _startControl() { if (_controlActive) return; - if (_cubit.state.targetDevice == null) { + if (_cubit.state.targetDevice?.deviceName != widget.deviceId) { debugPrint('❌ [PV遥控] targetDevice 为空,跳过控制循环与权限查询'); return; } + // 🔥 上一轮折叠的 deactivate 还在收尾:先记需求,等它结束后再起循环, + // 否则会被它尾部的 stopControlLoop() 把新循环一并关掉。 + if (_deactivating) { + _restartAfterDeactivate = true; + debugPrint('⚠️ [PV遥控] 退出控制态尚未完成,推迟进入控制态'); + return; + } _controlActive = true; // deactivate 会清除急停态,重新进入从“未急停”开始,与通道保持同步。 _emergencyOn = false; @@ -259,13 +344,25 @@ class _PvControlPageState extends State { } /// 退出控制态:归零、停循环、释放权限(通道内部完成,不阻塞 UI)。 - void _stopControl() { + /// + /// [notify] 是否弹“释放控制权”结果提示:退页(dispose)需要, + /// HUD 折叠回大屏属常规视图切换,不打扰。 + void _stopControl({bool notify = true}) { if (!_controlActive) return; _controlActive = false; + _deactivating = true; + _restartAfterDeactivate = false; // dispose 不能 await,fire-and-forget;拿到释放结果后弹与远程遥控页一致的提示 //(CapsuleToast 走根 Overlay,页面销毁瞬间也能显示);无设备未发起释放则不弹。 _channel.deactivate().then((released) { - if (released == null || !mounted) return; + // 🔥 收尾:先解除“正在退出”标记,若期间用户已重新进入 HUD 就补起一次控制态。 + _deactivating = false; + if (_restartAfterDeactivate) { + _restartAfterDeactivate = false; + // 页面已销毁(退页与折叠竞态)时绝不能再起循环:那会留下一个无主的控制循环。 + if (mounted) _startControl(); + } + if (released == null || !mounted || !notify) return; final tr = AppLocalizations.of(context); CapsuleToast.show( released @@ -321,11 +418,10 @@ class _PvControlPageState extends State { final requestingPlatform = state.requestingPlatform ?? tr.translate('pv_control.perm_unknown_platform'); - final dialogContent = - tr - .translate('pv_control.perm_request_body') - .replaceAll('{platform}', requestingPlatform) - .replaceAll('{deviceId}', requestingDeviceId); + final dialogContent = tr + .translate('pv_control.perm_request_body') + .replaceAll('{platform}', requestingPlatform) + .replaceAll('{deviceId}', requestingDeviceId); final cubit = _cubit; return showDialog( context: context, @@ -451,6 +547,8 @@ class _PvControlPageState extends State { backgroundColor: Colors.transparent, // 面板内部自带 maxHeight + 滚动,这里放开模态默认高度限制 isScrollControlled: true, + // 横屏底部有手势条 / 虚拟键:避开安全区,否则最后一行会被压住 + useSafeArea: true, // 薄遮罩:能看见底下视频与告警/倒车效果的变化 barrierColor: Colors.black.withValues(alpha: 0.35), builder: (_) { @@ -470,10 +568,16 @@ class _PvControlPageState extends State { videoFit: _videoFit, simObstacle: _simObstacle, simReverse: _simReverse, + showJoysticks: _showJoysticks, onVideoSourceChanged: (v) => update(() => _videoSource = v), onVideoFitChanged: (v) => update(() => _videoFit = v), onSimObstacleChanged: (v) => update(() => _simObstacle = v), - onSimReverseChanged: (v) => update(() => _simReverse = v), + // 倒车影像:不靠面板回传的 value 取反(面板是独立 Route, + // value 可能是上一帧的旧值,会导致点了“关”实际又传回 true), + // 直接按页面当前状态取反,保证每次点击一定翻转。 + onSimReverseChanged: (_) => + update(() => _simReverse = !_simReverse), + onShowJoysticksChanged: (v) => update(() => _showJoysticks = v), ); }, ); @@ -498,7 +602,9 @@ class _PvControlPageState extends State { if (!mounted) return; if (path != null) { CapsuleToast.show( - AppLocalizations.of(context).translate('pv_control.toast_screenshot_saved'), + AppLocalizations.of( + context, + ).translate('pv_control.toast_screenshot_saved'), ); } // 截图后弹出预览 @@ -567,7 +673,16 @@ class _PvControlPageState extends State { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => ControlModeEntryGate( + key: ValueKey('remote-mode/${widget.deviceId}'), + deviceId: widget.deviceId, + deviceName: widget.deviceName, + target: ControlModeTarget.remote, + onReady: _startControl, + child: _buildPage(context), + ); + + Widget _buildPage(BuildContext context) { return Scaffold( backgroundColor: PvColors.bgDeepest, body: DecoratedBox( @@ -578,23 +693,33 @@ class _PvControlPageState extends State { left: false, right: false, child: _wrapVideoProvider( - BlocBuilder( - builder: (context, dsState) { - final telemetry = _resolveTelemetry(dsState); - return _mode == PvViewMode.manualControl - ? PvManualControlView( - streamUrl: _streamUrl, - telemetry: telemetry, - onCollapse: _onCollapseManual, - onEmergencyStop: _onEmergencyStop, - videoSource: _videoSource, - videoFit: _videoFit, - simReverse: _simReverse, - deviceSn: widget.deviceId, - onSettings: _openSettings, - channel: _channel, - ) - : _buildNormalLayout(telemetry); + StreamBuilder( + initialData: _cubit.state, + stream: _cubit.stream, + builder: (context, remoteSnap) { + // 当前选中的设备名(顶栏 / 信息卡 / HUD 同源) + final deviceName = _resolveDeviceName(remoteSnap.data); + return BlocBuilder( + builder: (context, dsState) { + final telemetry = _resolveTelemetry(dsState, deviceName); + return _mode == PvViewMode.manualControl + ? PvManualControlView( + streamUrl: _streamUrl, + telemetry: telemetry, + onCollapse: _onCollapseManual, + onEmergencyStop: _onEmergencyStop, + videoSource: _videoSource, + videoFit: _videoFit, + simReverse: _simReverse, + showJoysticks: _showJoysticks, + deviceSn: widget.deviceId, + deviceName: deviceName, + onSettings: _openSettings, + channel: _channel, + ) + : _buildNormalLayout(telemetry, deviceName); + }, + ); }, ), ), @@ -603,7 +728,7 @@ class _PvControlPageState extends State { ); } - Widget _buildNormalLayout(PvTelemetry telemetry) { + Widget _buildNormalLayout(PvTelemetry telemetry, String deviceName) { // ===== 三行高度比例:顶部导航 : 第一行(视频+右列) : 第二行(控制栏) ===== // 三行高度比例:顶部导航 : 第一行 : 第二行。Expanded 的 flex 只能是整数, // 这里写成 2 : 14 : 6(即 1 : 7 : 3)。第二行比 2.5 份再多一点, @@ -618,6 +743,7 @@ class _PvControlPageState extends State { Expanded( flex: navFlex, child: _PvTopNav( + deviceName: deviceName, onBack: () => Navigator.of(context).pop(), onSettings: _openSettings, ), @@ -646,6 +772,7 @@ class _PvControlPageState extends State { telemetry: telemetry, onScreenshot: _onScreenshot, onFullscreen: _onManualControl, + onLocate: _onLocate, videoSource: _videoSource, videoFit: _videoFit, ), @@ -687,7 +814,14 @@ class _PvControlPageState extends State { /// 顶部导航栏:左侧返回、中间标题/副标题、右侧设置与更多 class _PvTopNav extends StatelessWidget { - const _PvTopNav({required this.onBack, required this.onSettings}); + const _PvTopNav({ + required this.deviceName, + required this.onBack, + required this.onSettings, + }); + + /// 当前选中的设备名(副标题位置展示) + final String deviceName; final VoidCallback onBack; final VoidCallback onSettings; @@ -719,9 +853,10 @@ class _PvTopNav extends StatelessWidget { style: PvType.navTitle, ), Text( - AppLocalizations.of( - context, - ).translate('pv_control.pv_robot_remote_control'), + // 当前控制的是哪台设备(原先这里是写死的“光伏电站机器人远程遥控”) + deviceName, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: PvType.navSubtitle, ), ], @@ -779,12 +914,9 @@ class _ScreenshotPreviewDialog extends StatelessWidget { if (path != null) ...[ const SizedBox(height: 10), Text( - AppLocalizations.of( - context, - ).translate('pv_control.screenshot_saved_path').replaceAll( - '{path}', - path!, - ), + AppLocalizations.of(context) + .translate('pv_control.screenshot_saved_path') + .replaceAll('{path}', path!), maxLines: 1, overflow: TextOverflow.ellipsis, style: PvType.label, @@ -797,9 +929,7 @@ class _ScreenshotPreviewDialog extends StatelessWidget { TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( - AppLocalizations.of( - context, - ).translate('pv_control.close'), + AppLocalizations.of(context).translate('pv_control.close'), ), ), ], diff --git a/lib/features/pv_control/presentation/widgets/pv_info_column.dart b/lib/features/pv_control/presentation/widgets/pv_info_column.dart index 90b0a1bb..53d45f5a 100644 --- a/lib/features/pv_control/presentation/widgets/pv_info_column.dart +++ b/lib/features/pv_control/presentation/widgets/pv_info_column.dart @@ -253,7 +253,12 @@ class _DriveModeCard extends StatelessWidget { ), const SizedBox(height: 1), Text( - telemetry.taskStatus, + // 遥测未带任务状态时,UI 层用当前语言兜底 + telemetry.taskStatus.isEmpty + ? AppLocalizations.of( + context, + ).translate('pv_control.task_patrol_running') + : telemetry.taskStatus, style: const TextStyle( color: PvColors.textSecondary, fontSize: 10, diff --git a/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart b/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart index fd0350d2..83e3aef6 100644 --- a/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart +++ b/lib/features/pv_control/presentation/widgets/pv_manual_control_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/components/control_mode_text.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/remote_control/domain/entities/reverse_camera_data.dart'; @@ -64,7 +65,9 @@ class PvManualControlView extends StatefulWidget { this.videoSource = PvVideoSource.webrtc, this.videoFit = PvVideoFit.contain, this.simReverse = false, + this.showJoysticks = true, this.deviceSn = '', + this.deviceName = '', this.onSettings, this.channel, }); @@ -81,12 +84,20 @@ class PvManualControlView extends StatefulWidget { /// 视频展示方式(设置面板可改):contain 完整显示 / cover 等比铺满(裁边)。 final PvVideoFit videoFit; - /// 倒车影像模拟开关(设置面板控制):开启后左栏后视卡叠加倒车引导线。 + /// 「倒车影像」开关(设置面板控制):控制**主画面中间那组外黄内绿的车道线 + /// 与流动导向箭头**([_buildArOverlay])的显隐,不控制左下角后视卡。 final bool simReverse; + /// 是否显示左右两个方向摇杆(设置面板控制):关闭后摇杆与回中按钮一起隐藏, + /// 底部操作条自动向两侧摊开,把画面完整让给视频。 + final bool showJoysticks; + /// 设备 SN:HUD 地图按它过滤后端推送的规划路线(订阅切换由页面层负责)。 final String deviceSn; + /// 当前选中的设备名(顶栏展示,看清控制的是哪台设备) + final String deviceName; + /// 顶栏「设置」按钮回调(弹出设置面板)。 final VoidCallback? onSettings; @@ -163,6 +174,13 @@ class _PvManualControlViewState extends State Offset? _leftPipPos; Offset? _rightPipPos; + /// 上一次真正下发出去的双轴值:与旧版摇杆的 `_lastSentY` / `_lastX` 同一意图, + /// 值没变化就不重发。摇杆每移动一个 pointer 事件就回调一次,不去重会让 + /// updateOriginX/Y 里的 emit 高频重建整棵 HUD(本页挂的是 StreamBuilder, + /// 没有旧版 BlocBuilder buildWhen 那层隔离,只能从源头少发)。 + int _lastDriveY = 0; + int _lastDriveX = 0; + /// 折叠动画时长与曲线(两侧共用,保证节奏一致)。 static const Duration _foldDur = Duration(milliseconds: 280); static const Curve _foldCurve = Curves.easeInOutCubic; @@ -195,6 +213,20 @@ class _PvManualControlViewState extends State return '${two(n.hour)}:${two(n.minute)}:${two(n.second)}'; } + /// 摇杆被关掉的那一帧立刻停车:隐藏只是不画了,手指原来的按压/下发的 + /// 方向指令不会自动停,必须显式发一次全停(同时把倒车引导线回正)。 + @override + void didUpdateWidget(covariant PvManualControlView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.showJoysticks && !widget.showJoysticks) { + // 归零后清掉去重基准:否则下次推到同一个值会被当成“没变化”吞掉。 + _lastDriveY = 0; + _lastDriveX = 0; + unawaited(widget.channel?.stopAllMovement()); + _steerDeg.value = 0; + } + } + /// 定位质量枚举→文案(与设备监控弹窗/蓝牙协议同一套 fix_status 语义)。 static String _qualText(int? q) => switch (q) { null => 'pv_control.unknown', @@ -209,12 +241,11 @@ class _PvManualControlViewState extends State _ => 'pv_control.unknown', }; - /// 控制模式→文案(与“设备数据监控” _getControlModeKey 同口径:3=远程,其余=本地)。 + /// 控制模式→文案 key(统一走 lib/components/control_mode_text.dart)。 + /// 此处原本已经能分辨「空 = 未知」,但数据层把「没给」的默认值写成了 '0', + /// 于是它永远拿不到空串;现在 0~4 按协议展开,非协议值一律未知。 static String _controlModeText(String? mode) { - if (mode == null || mode.isEmpty) return 'pv_control.unknown'; - return int.tryParse(mode.trim()) == 3 - ? 'pv_control.mode_remote' - : 'pv_control.mode_local'; + return controlModeKey(mode) ?? 'pv_control.unknown'; } @override @@ -317,15 +348,18 @@ class _PvManualControlViewState extends State fit: StackFit.expand, children: [ // 导向路径 + 车道线(箭头随 _flow 递进流动;整条脊柱随右摇杆转向弯曲) - ValueListenableBuilder( - valueListenable: _steerDeg, - builder: (context, deg, _) => AnimatedBuilder( - animation: _flow, - builder: (context, _) => CustomPaint( - painter: _PvArPainter(progress: _flow.value, steering: deg), + // 由设置面板「倒车影像」开关控制:关掉后主画面中间这组黄色/绿色 + // 引导线与流动箭头一起隐藏,画面回到干净的视频。 + if (widget.simReverse) + ValueListenableBuilder( + valueListenable: _steerDeg, + builder: (context, deg, _) => AnimatedBuilder( + animation: _flow, + builder: (context, _) => CustomPaint( + painter: _PvArPainter(progress: _flow.value, steering: deg), + ), ), ), - ), // 障碍物告警成套效果:收到机器推送(或设置面板模拟开启)时, // 屏幕泛红呼吸 + 障碍框×2 + 预警横幅**同时**出现,同一 obstacleAlert 驱动。 if (widget.telemetry.obstacleAlert) ...[ @@ -560,6 +594,22 @@ class _PvManualControlViewState extends State 18, ).copyWith(color: PvHudColors.textPrimary), ), + if (widget.deviceName.isNotEmpty) ...[ + SizedBox(width: d(8)), + // 当前控制设备名:只展示不参与操作,过长省略不挤占其他胶囊 + ConstrainedBox( + constraints: BoxConstraints(maxWidth: d(160)), + child: Text( + widget.deviceName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: s( + PvHudType.value, + 12, + ).copyWith(color: PvHudColors.textSecondary), + ), + ), + ], SizedBox(width: d(8)), _onlineBadge(), SizedBox(width: d(8)), @@ -1094,29 +1144,29 @@ class _PvManualControlViewState extends State } /// 左下后视图卡:标题与全屏按钮都浮在画面之上;高度由左栏列表项给定。 + /// + /// 注意:设置面板里的「倒车影像」开关**不控制这张卡**,它管的是主画面中间 + /// 那组黄/绿车道线与流动箭头(见 [_buildArOverlay])。后视卡保持稳定的 + /// 后视形态常驻,避免一次点击同时改两处、看起来像“开关失灵”。 Widget _rearCard() { final tr = AppLocalizations.of(context); return PvRearViewCard( scale: _sc, - // 倒车影像:设置面板开启后叠加引导线;弯曲实时跟右摇杆转向。 + // 后视引导线弯曲实时跟右摇杆转向。 // 未来接入下位机后,ReverseCameraData 的 gear/steeringAngle/ // rearDistanceCm 全部改成读回传数据即可,本组件不改。 - title: widget.simReverse - ? tr.translate('pv_control.reverse_camera') - : tr.translate('pv_control.rear_view'), - overlay: widget.simReverse - ? ValueListenableBuilder( - valueListenable: _steerDeg, - builder: (context, deg, _) => ReverseGuideOverlay( - data: ReverseCameraData( - gear: 2, - steeringAngle: deg, - speed: 0.4, - rearDistanceCm: 120, - ), - ), - ) - : null, + title: tr.translate('pv_control.reverse_camera'), + overlay: ValueListenableBuilder( + valueListenable: _steerDeg, + builder: (context, deg, _) => ReverseGuideOverlay( + data: ReverseCameraData( + gear: 2, + steeringAngle: deg, + speed: 0.4, + rearDistanceCm: 120, + ), + ), + ), ); } @@ -1617,6 +1667,8 @@ class _PvManualControlViewState extends State } Widget _buildJoysticks(double w, double h) { + // 设置里关掉「遥控摇杆」:整块(含回中按钮)不渲染,画面全留给视频 + if (!widget.showJoysticks) return const SizedBox.shrink(); final radius = _joyRadius(w, h); final size = radius * 2; // 折叠时摇杆向外侧滑动,展开时向内收,避免与两侧面板挤压 @@ -1633,8 +1685,16 @@ class _PvManualControlViewState extends State radius: radius, axisHint: AxisHint.forwardBackward, skin: _joystickSkin(), - onValueChanged: (v) => widget.channel?.sendDriveY(v.y.toInt()), - onPanEnd: () async => widget.channel?.stopAllMovement(), + onValueChanged: (v) { + final int y = v.y.toInt(); + if (y == _lastDriveY) return; + _lastDriveY = y; + widget.channel?.sendDriveY(y); + }, + onPanEnd: () async { + _lastDriveY = 0; + await widget.channel?.stopAllMovement(); + }, ), ), AnimatedPositioned( @@ -1648,11 +1708,14 @@ class _PvManualControlViewState extends State skin: _joystickSkin(), onValueChanged: (v) { final int x = v.x.toInt().abs() < 1 ? 0 : v.x.toInt(); + if (x == _lastDriveX) return; + _lastDriveX = x; widget.channel?.sendDriveX(x); // 右摇杆(-100~100) → 转向角(-45°~45°),驱动引导线左右弯曲 _steerDeg.value = (x * 0.45).clamp(-45.0, 45.0); }, onPanEnd: () async { + _lastDriveX = 0; _steerDeg.value = 0; // 松手回正,引导线恢复直线 await widget.channel?.stopAllMovement(); }, @@ -1665,7 +1728,12 @@ class _PvManualControlViewState extends State left: leftJL + size + d(6), bottom: d(64), child: GestureDetector( - onTap: () => widget.channel?.stopAllMovement(), + onTap: () { + // 回中同样要清掉去重基准:不然摇杆球停在原处再推相同值会被吞掉。 + _lastDriveY = 0; + _lastDriveX = 0; + widget.channel?.stopAllMovement(); + }, child: Container( width: d(40), height: d(40), @@ -1689,13 +1757,15 @@ class _PvManualControlViewState extends State final size = radius * 2; final leftJL = _leftCollapsed ? d(24) : w * 0.22; final rightJR = _rightCollapsed ? d(24) : w * 0.16; + // 摇杆隐藏后不再需要给两侧让位:底部条直接向两侧摊开,重心回到屏幕中心 + final showJoy = widget.showJoysticks; return AnimatedPositioned( duration: _foldDur, curve: _foldCurve, bottom: d(14), // 在两个摇杆内侧之间动态居中:折叠腾出空间后自动放宽、回正 - left: leftJL + size + d(8), - right: rightJR + size + d(8), + left: showJoy ? leftJL + size + d(8) : d(12), + right: showJoy ? rightJR + size + d(8) : d(12), child: Center( // 窄屏(手机横屏)两摇杆之间的空间不够时整体等比缩小,不让四个按钮溢出 child: FittedBox( diff --git a/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart b/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart index d4c3c6e3..6c050614 100644 --- a/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart +++ b/lib/features/pv_control/presentation/widgets/pv_settings_sheet.dart @@ -9,12 +9,13 @@ import '../theme/pv_hud_theme.dart'; /// 页面设置面板(底部弹出,遮罩半透明可透视下方视频,边调边看效果)。 /// -/// 纯展示 + 回调:所有设置状态由 PvControlPage 持有。当前四项: +/// 纯展示 + 回调:所有设置状态由 PvControlPage 持有。当前五项: /// - 视频源:以太网 RTSP / HDMI(USB 采集卡) / WebRTC,切换即重建对应链路 cubit; /// - 视频展示:完整显示(不裁画面、可能留黑边)/ 铺满屏幕(无黑边、裁掉边缘); /// - 模拟障碍物信息:真实推送未接入前手动触发,开启 = 相当于收到机器告警推送, /// 预警横幅 + AR 障碍框 + 屏幕四周泛红**同时**显示; -/// - 倒车影像:开启后左栏后视卡叠加倒车引导线效果(模拟倒车挡)。 +/// - 倒车影像:开启后左栏后视卡叠加倒车引导线效果(模拟倒车挡); +/// - 遥控摇杆(仅手动控制 HUD 显示):关闭后隐藏左右两个方向摇杆与回中按钮。 /// /// 皮肤两套(由 [light] 选择):普通态大屏沿用原深蓝面板;手动控制全屏视图 /// (HUD)用近白玻璃卡,与其上的顶栏 / 侧栏 / 底部条保持同一视觉体系。 @@ -25,10 +26,12 @@ class PvSettingsSheet extends StatelessWidget { required this.videoFit, required this.simObstacle, required this.simReverse, + required this.showJoysticks, required this.onVideoSourceChanged, required this.onVideoFitChanged, required this.onSimObstacleChanged, required this.onSimReverseChanged, + required this.onShowJoysticksChanged, this.light = false, }); @@ -36,10 +39,15 @@ class PvSettingsSheet extends StatelessWidget { final PvVideoFit videoFit; final bool simObstacle; final bool simReverse; + + /// 是否显示左右两个方向摇杆(仅手动控制 HUD 有摇杆,设置面板可关掉)。 + final bool showJoysticks; + final ValueChanged onVideoSourceChanged; final ValueChanged onVideoFitChanged; final ValueChanged onSimObstacleChanged; final ValueChanged onSimReverseChanged; + final ValueChanged onShowJoysticksChanged; /// 是否套用 HUD 浅色玻璃皮肤(默认 false = 普通态大屏的深色皮肤,与改造前一致)。 final bool light; @@ -65,14 +73,16 @@ class PvSettingsSheet extends StatelessWidget { final skin = _skin; // 横屏可用高度有限:最大高度限幅 + 可滚动防底部裁切; // 视频源行用 Wrap,窄屏 / 大字体下药丸自动换行不溢出。 - final maxH = MediaQuery.sizeOf(context).height * 0.72; + // 限幅放宽到 0.86:多一行开关后旧值会把最后一行压到手势条下面。 + final maxH = MediaQuery.sizeOf(context).height * 0.86; return ConstrainedBox( constraints: BoxConstraints(maxHeight: maxH), child: SingleChildScrollView( child: Padding( - padding: const EdgeInsets.fromLTRB(18, 0, 18, 12), + // 底部留白加大:面板整体往上抬,不贴着屏幕底边 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 18), child: Container( - padding: const EdgeInsets.fromLTRB(14, 4, 10, 8), + padding: const EdgeInsets.fromLTRB(12, 2, 8, 6), decoration: BoxDecoration( color: skin.panel, borderRadius: BorderRadius.circular(16), @@ -83,11 +93,11 @@ class PvSettingsSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 标题行 + 关闭 + // 标题行 + 关闭(比原尺寸小一号,给下面的开关行腾高度) Row( children: [ - Icon(Icons.settings_outlined, color: skin.accent, size: 18), - const SizedBox(width: 8), + Icon(Icons.settings_outlined, color: skin.accent, size: 16), + const SizedBox(width: 6), Text( tr.translate('pv_control.nav_settings'), style: PvType.cardTitle.copyWith( @@ -99,19 +109,19 @@ class PvSettingsSheet extends StatelessWidget { icon: Icon( Icons.close, color: skin.textSecondary, - size: 20, + size: 18, ), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: - const BoxConstraints(minWidth: 32, minHeight: 32), + const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: () => Navigator.of(context).pop(), ), ], ), // 视频源(Wrap:空间不够自动换第二行) Wrap( - spacing: 8, + spacing: 6, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -128,9 +138,9 @@ class PvSettingsSheet extends StatelessWidget { ], ), // 视频展示(适配方式)+ 一句取舍说明 - const SizedBox(height: 6), + const SizedBox(height: 4), Wrap( - spacing: 8, + spacing: 6, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -152,7 +162,7 @@ class PvSettingsSheet extends StatelessWidget { ), ], ), - const SizedBox(height: 4), + const SizedBox(height: 2), _switchTile( icon: Icons.warning_amber_rounded, iconColor: simObstacle ? skin.danger : skin.textTertiary, @@ -169,6 +179,17 @@ class PvSettingsSheet extends StatelessWidget { value: simReverse, onChanged: onSimReverseChanged, ), + // 摇杆开关:新版远程遥控的设置面板里始终可见。 + // 大屏页本身没有摇杆,这个开关预设的是「全屏手动控制」里的显示与否, + // 进 HUD 后立即生效(淡出淡入不需要重启页面)。 + _switchTile( + icon: Icons.gamepad_outlined, + iconColor: showJoysticks ? skin.accent : skin.textTertiary, + title: tr.translate('pv_control.set_show_joysticks'), + subtitle: tr.translate('pv_control.set_show_joysticks_sub'), + value: showJoysticks, + onChanged: onShowJoysticksChanged, + ), ], ), ), @@ -190,10 +211,10 @@ class PvSettingsSheet extends StatelessWidget { behavior: HitTestBehavior.opaque, child: AnimatedContainer( duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: selected ? skin.accentSoft : skin.inner, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), border: Border.all( color: selected ? skin.accent : skin.border, ), @@ -222,11 +243,11 @@ class PvSettingsSheet extends StatelessWidget { return InkWell( onTap: () => onChanged(!value), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ - Icon(icon, size: 20, color: iconColor), - const SizedBox(width: 10), + Icon(icon, size: 18, color: iconColor), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -245,11 +266,13 @@ class PvSettingsSheet extends StatelessWidget { ], ), ), - // Switch 自身禁用手势,统一走整行 onTap,避免两套状态 + // Switch 自身禁用手势,统一走整行 onTap,避免两套状态; + // shrinkWrap 去掉默认 48dp 点按区,视觉上跟着整体缩一号 IgnorePointer( child: Switch( value: value, onChanged: (_) {}, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, activeThumbColor: skin.switchActiveThumb, activeTrackColor: skin.switchActiveTrack, inactiveThumbColor: skin.switchInactiveThumb, diff --git a/lib/features/pv_control/presentation/widgets/pv_video_area.dart b/lib/features/pv_control/presentation/widgets/pv_video_area.dart index e1141f32..3bb3f64e 100644 --- a/lib/features/pv_control/presentation/widgets/pv_video_area.dart +++ b/lib/features/pv_control/presentation/widgets/pv_video_area.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart'; import '../models/pv_telemetry.dart'; @@ -64,14 +65,14 @@ class PvVideoArea extends StatelessWidget { ), // ② 顶部左侧:实时视频 + 分辨率/帧率 - Positioned(left: 14, top: 12, child: _buildTopLeftBadge()), + Positioned(left: 14, top: 12, child: _buildTopLeftBadge(context)), // ③ 顶部中间:障碍物预警 if (telemetry.obstacleAlert) Positioned(left: 0, right: 0, top: 10, child: _buildObstacleBanner()), // ④ 顶部右侧:网络状态 - Positioned(right: 14, top: 12, child: _buildNetworkBadge()), + Positioned(right: 14, top: 12, child: _buildNetworkBadge(context)), // ⑤ 右侧工具按钮列 if (showSideTools) @@ -112,7 +113,7 @@ class PvVideoArea extends StatelessWidget { return _VideoPlaceholder(); } - Widget _buildTopLeftBadge() { + Widget _buildTopLeftBadge(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -120,9 +121,11 @@ class PvVideoArea extends StatelessWidget { children: [ const Icon(Icons.circle, size: 8, color: PvColors.danger), const SizedBox(width: 5), - const Text( - '实时视频', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('pv_control.realtime_video'), + style: const TextStyle( color: PvColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w700, @@ -146,8 +149,9 @@ class PvVideoArea extends StatelessWidget { Widget _buildObstacleBanner() => const _ObstacleBannerPill(); - Widget _buildNetworkBadge() { + Widget _buildNetworkBadge(BuildContext context) { final ok = telemetry.networkNormal; + final l10n = AppLocalizations.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -163,7 +167,9 @@ class PvVideoArea extends StatelessWidget { Icon(Icons.wifi, size: 14, color: ok ? PvColors.online : PvColors.warning), const SizedBox(width: 4), Text( - ok ? '网络良好' : '网络异常', + ok + ? l10n.translate('pv_control.network_good') + : l10n.translate('pv_control.network_bad'), style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, @@ -202,13 +208,18 @@ class _VideoPlaceholder extends StatelessWidget { Widget build(BuildContext context) { return Container( color: Colors.black87, - child: const Center( + child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.videocam_off, size: 48, color: Colors.white38), - SizedBox(height: 8), - Text('视频未加载', style: TextStyle(color: Colors.white54, fontSize: 12)), + const Icon(Icons.videocam_off, size: 48, color: Colors.white38), + const SizedBox(height: 8), + Text( + AppLocalizations.of( + context, + ).translate('pv_control.video_not_loaded'), + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), ], ), ), @@ -276,9 +287,11 @@ class _ObstacleBannerPillState extends State<_ObstacleBannerPill> const SizedBox(width: 7), const Icon(Icons.warning_rounded, color: Colors.white, size: 16), const SizedBox(width: 5), - const Text( - '障碍物预警', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('pv_control.obstacle_alert'), + style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w800, diff --git a/lib/features/pv_control/presentation/widgets/pv_work_map.dart b/lib/features/pv_control/presentation/widgets/pv_work_map.dart index f5b2bc8c..8eca5c70 100644 --- a/lib/features/pv_control/presentation/widgets/pv_work_map.dart +++ b/lib/features/pv_control/presentation/widgets/pv_work_map.dart @@ -6,16 +6,19 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/core/logging/work_trace_diagnostics.dart'; import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/route_status_entity.dart'; import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/HeadingPointer.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/app_base_map_layer.dart'; import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../theme/pv_colors.dart'; +import '../models/work_trace_recorder.dart'; /// HUD 作业地图:与「路径规划」同源、同图层、同样式的**只读**实时地图。 /// @@ -50,11 +53,22 @@ class PvWorkMap extends StatefulWidget { this.draftCompleted = false, this.draftObstacle = false, this.draftCrosshair = false, + this.returnPoints = const [], this.followDevice = true, this.recenterSignal = 0, this.clearTraceSignal = 0, this.onCenterChanged, this.onDevicePosition, + this.recordLocationTrace = true, + this.workTrace, + this.taskTrace, + this.expectedTaskId, + this.traceDiagnosticsEnabled = false, + this.showDevice = true, + this.extraMapLayers = const [], + this.focusPoints = const [], + this.focusSignal = 0, + this.focusPadding = const EdgeInsets.all(40), }); /// 设备 SN:用于过滤路线推送(deviceId 不匹配的消息直接丢弃)。 @@ -115,6 +129,12 @@ class PvWorkMap extends StatefulWidget { /// 是否准星打点模式:画「中心 → 末点」蓝色连线、距离文字、草稿多边形。 final bool draftCrosshair; + // ===== 一键返航图层(独立于圈地草稿层,默认空=不绘制)===== + + /// 一键返航点:紫色虚线串联 + 紫色编号徽标(首点显示「H」)。 + /// 与作业区橙色打点、禁行区红色框配色区分,互不影响。 + final List returnPoints; + /// 是否让地图随设备位置自动居中。区域编辑时传 false: /// 否则每次定位推送都会把地图挪走,正在打的位置就跑了。 final bool followDevice; @@ -133,18 +153,107 @@ class PvWorkMap extends StatefulWidget { /// 设备实时位置变化(无效/清空时回调 null),供「设备位置打点」取当前坐标。 final ValueChanged? onDevicePosition; + /// 普通地图的定位轨迹;作业中心的组合轨迹由 taskTrace 独立控制。 + final bool recordLocationTrace; + final List? workTrace; + final WorkTraceInput? taskTrace; + final String? expectedTaskId; + final bool traceDiagnosticsEnabled; + final bool showDevice; + + /// 业务页面提供独立叠加层,共享地图无需依赖具体分区业务。 + final List extraMapLayers; + final List focusPoints; + final int focusSignal; + final EdgeInsets focusPadding; + @override State createState() => _PvWorkMapState(); } class _PvWorkMapState extends State { + final _traceDiagnostics = WorkTraceDiagnostics(); + final _taskTraceRecorder = WorkTraceRecorder(); + Object? _lastTraceRender; + List get _visibleTracePoints => widget.taskTrace != null + ? _taskTraceRecorder.points + : widget.workTrace ?? _trace; + + void _synchronizeTaskTrace() { + final input = widget.taskTrace; + if (input == null) { + _taskTraceRecorder.reset(); + return; + } + _taskTraceRecorder.synchronize(deviceId: widget.deviceSn, input: input); + if (_pos != null) _taskTraceRecorder.addPosition(_pos!); + } + + void _logTraceRender(List points) { + if (!widget.traceDiagnosticsEnabled) return; + final signature = ( + widget.deviceSn, + widget.expectedTaskId, + widget.recordLocationTrace, + widget.workTrace != null, + widget.taskTrace?.recording, + widget.taskTrace?.visible, + widget.taskTrace?.completedPoints.length, + points.length, + points.isEmpty ? null : points.last, + widget.previewRoute.length, + _routeTaskId, + _route.length, + _ready, + ); + if (_lastTraceRender == signature) return; + _lastTraceRender = signature; + _traceDiagnostics.log( + '地图画线', + { + 'deviceId': widget.deviceSn, + 'taskId': widget.expectedTaskId, + 'source': widget.taskTrace != null + ? (_taskTraceRecorder.hasCompletedPoints ? '到达点+实时定位' : '开始后定位轨迹') + : widget.workTrace != null + ? '任务completedPoints' + : '定位累计轨迹', + 'taskTraceRecording': widget.taskTrace?.recording, + 'taskTraceVisible': widget.taskTrace?.visible, + 'completedPoints': widget.taskTrace?.completedPoints.length, + 'recordLocationTrace': widget.recordLocationTrace, + 'mapReady': _ready, + 'mapCenter': !_ready + ? null + : { + 'lat': _map.camera.center.latitude, + 'lng': _map.camera.center.longitude, + }, + 'mapZoom': !_ready ? null : _map.camera.zoom, + 'points': points.length, + 'previewRoutePoints': widget.previewRoute.length, + 'realtimeRoutePoints': _route.length, + 'greenPolyline': points.length >= 2, + 'lastPoint': points.isEmpty + ? null + : {'lat': points.last.latitude, 'lng': points.last.longitude}, + 'reason': points.isEmpty + ? '没有轨迹点' + : points.length == 1 + ? '只有一个点,需至少两个点才能画线' + : '满足绿色Polyline绘制条件', + }, + sampleKey: + 'map/${widget.deviceSn}/${widget.expectedTaskId}/${points.length < 2 ? points.length : "2+"}/$_ready', + ); + } + // ===== 与路径规划一致的常量 ===== - static const _esriUrl = - 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; static const _centerZoom = 18.0; // 路径规划回中/跟随都用 18 级 static const _previewZoom = 21.0; // 与路径规划选中路线后的定位级别一致 static const _maxTrace = 600; // 绿线最多保留的点数(约 5 分钟推送) static const _minTraceStep = 0.3; // 米:小于此间距的点丢弃(抖动过滤) + static const _returnColor = Color(0xFF7B61FF); // 返航点配色(紫) final MapController _map = MapController(); final Distance _dist = const Distance(); @@ -167,6 +276,7 @@ class _PvWorkMapState extends State { @override void initState() { super.initState(); + _synchronizeTaskTrace(); // 图层范围仓库:与路径规划同一套用法(动态刷新 + 变更通知,失败自动回退内置兜底) StationLayerRepo.instance.addListener(_onStationLayersUpdated); StationLayerRepo.instance.refresh(); @@ -208,14 +318,32 @@ class _PvWorkMapState extends State { // 页面点了「刷新」:已走轨迹作废(同路径规划 traceManager.reset()) if (oldWidget.clearTraceSignal != widget.clearTraceSignal) { _trace.clear(); + _taskTraceRecorder.reset(); } + _synchronizeTaskTrace(); + if (oldWidget.focusSignal != widget.focusSignal) _fitFocusGeometry(); } /// 预览的全部几何(轨迹 + 边界框):视野与定位都以它为准 List get _previewPoints => [ - ...widget.previewOuter, - ...widget.previewRoute, - ]; + ...widget.previewOuter, + ...widget.previewRoute, + ]; + + void _fitFocusGeometry() { + if (widget.focusPoints.isEmpty) return; + _follow = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_ready || widget.focusPoints.isEmpty) return; + _map.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds.fromPoints(widget.focusPoints), + padding: widget.focusPadding, + maxZoom: _previewZoom, + ), + ); + }); + } /// 把视野移到预览几何中心。刻意不用 CameraFit: /// 只要 center + 固定 zoom,跟 flutter_map 版本差异无关。 @@ -254,6 +382,11 @@ class _PvWorkMapState extends State { /// 多一道过滤反而会因为 payload 字段格式不同把运行中的路线丢掉。 void _onRouteMessage(RouteStatusEntity msg) { if (msg.data.isEmpty || !mounted) return; + if (widget.expectedTaskId != null && + msg.taskId > 0 && + '${msg.taskId}' != widget.expectedTaskId) { + return; + } setState(() { // 新任务 = 上一段的行走轨迹作废(路径规划选中任务时同样会 reset 轨迹) if (msg.taskId != _routeTaskId) { @@ -266,12 +399,47 @@ class _PvWorkMapState extends State { /// 定位推送来了:攒轨迹 + 按需把机器移到正中间 void _onLocation(DeviceStatusUpdated state) { + if (!widget.showDevice) return; final p = _validLatLng(state.status.latitude, state.status.longitude); - if (p == null) return; // 无效坐标不画点、不追(与路径规划一致) + if (widget.traceDiagnosticsEnabled) { + _traceDiagnostics.log( + '定位收到', + { + 'deviceId': widget.deviceSn, + 'taskId': widget.expectedTaskId, + 'lat': state.status.latitude, + 'lng': state.status.longitude, + 'valid': p != null, + 'recordLocationTrace': widget.recordLocationTrace, + 'displayPoints': _visibleTracePoints.length, + 'taskTraceRecording': widget.taskTrace?.recording, + 'note': widget.taskTrace != null + ? (widget.taskTrace!.recording + ? '按作业轨迹规则接入实时车位' + : '任务未开始、暂停或结束,定位不追加到轨迹') + : widget.recordLocationTrace + ? '定位可累计本地轨迹' + : '定位只更新车标;本页绿色轨迹需要任务arrive到达点', + }, + sampleKey: 'location/${widget.deviceSn}/${widget.expectedTaskId}', + interval: const Duration(seconds: 5), + ); + } + if (p == null) { + if (widget.onDevicePosition != null) { + _pos = null; + widget.onDevicePosition?.call(null); + } + return; + } if (p == _pos) return; _pos = p; widget.onDevicePosition?.call(p); - _appendTrace(p); + if (widget.taskTrace != null) { + _taskTraceRecorder.addPosition(p); + } else if (widget.recordLocationTrace) { + _appendTrace(p); + } if (_follow && widget.followDevice) _scheduleMove(p); } @@ -328,11 +496,11 @@ class _PvWorkMapState extends State { } /// 中心到末点的距离文本(与路径规划同一套显示:1 公里内用米) - String _distanceText(LatLng a, LatLng b) { + String _distanceText(LatLng a, LatLng b, AppLocalizations l10n) { final d = _dist(a, b); return d < 1000 - ? '${d.toStringAsFixed(1)} 米' - : '${(d / 1000).toStringAsFixed(2)} 千米'; + ? '${d.toStringAsFixed(1)} ${l10n.translate('pv_control.unit_m')}' + : '${(d / 1000).toStringAsFixed(2)} ${l10n.translate('pv_control.unit_km')}'; } /// 进场中心:优先当前选中场站高清图层中心,无有效场站兜底到苏州地块(同路径规划) @@ -351,15 +519,27 @@ class _PvWorkMapState extends State { @override Widget build(BuildContext context) { return BlocListener( - listenWhen: (prev, cur) => cur is DeviceStatusUpdated, - listener: (context, state) => _onLocation(state as DeviceStatusUpdated), + listenWhen: (prev, cur) => + cur is DeviceStatusUpdated || widget.onDevicePosition != null, + listener: (context, state) { + if (state is DeviceStatusUpdated) { + _onLocation(state); + } else { + _pos = null; + widget.onDevicePosition?.call(null); + } + }, child: BlocBuilder( buildWhen: (prev, cur) => cur is DeviceStatusUpdated, builder: (context, state) { - final status = state is DeviceStatusUpdated ? state.status : null; + final status = widget.showDevice && state is DeviceStatusUpdated + ? state.status + : null; final marker = status == null ? null : _validLatLng(status.latitude, status.longitude); + final tracePoints = _visibleTracePoints; + _logTraceRender(tracePoints); return Stack( fit: StackFit.expand, children: [ @@ -378,26 +558,34 @@ class _PvWorkMapState extends State { ), onMapReady: () { _ready = true; - _fitPreviewRoute(); + if (widget.onDevicePosition != null && widget.showDevice) { + final current = context.read().state; + if (current is DeviceStatusUpdated) _onLocation(current); + } + if (widget.focusPoints.isNotEmpty) { + _fitFocusGeometry(); + } else { + _fitPreviewRoute(); + } // 进场先把当前中心告诉页面:准星还没动过时也能取到中心 - widget.onCenterChanged?.call(LatLng(_map.camera.center.latitude, - _map.camera.center.longitude)); + widget.onCenterChanged?.call( + LatLng( + _map.camera.center.latitude, + _map.camera.center.longitude, + ), + ); }, onPositionChanged: _notifyCenter, onMapEvent: _onMapEvent, onTap: (_, _) {}, // 空实现:地图本身不响应点击 ), children: [ - /// ESRI World Imagery(WGS84,全球高清卫星图,无需坐标转换) - TileLayer( - urlTemplate: _esriUrl, - userAgentPackageName: 'com.example.app', - minZoom: 0, - maxZoom: 19, - ), + /// 底图:中文=ESRI,非中文=Google(不可达自动回退 ESRI) + const AppBaseMapLayer(maxZoom: 22), /// 场站无人机高清影像:滑到哪个场站区域(zoom>=16)就叠哪个 if (_showStation) ..._stationLayers, + ...widget.extraMapLayers, /// ① 作业区域边界框(与路径规划同一套画法): /// 覆盖模式 = 灰色半透明多边形;自定义模式 = 绿色边界线。 @@ -465,9 +653,7 @@ class _PvWorkMapState extends State { PolylineLayer( key: const ValueKey('pv_draft_points'), polylines: [ - for (var i = 0; - i < widget.draftPoints.length - 1; - i++) + for (var i = 0; i < widget.draftPoints.length - 1; i++) Polyline( points: [ widget.draftPoints[i], @@ -522,6 +708,7 @@ class _PvWorkMapState extends State { _distanceText( widget.draftCenter!, widget.draftPoints.last, + AppLocalizations.of(context), ), textAlign: TextAlign.center, style: const TextStyle( @@ -574,9 +761,11 @@ class _PvWorkMapState extends State { PolylineLayer( key: const ValueKey('pv_draft_hole_points'), polylines: [ - for (var i = 0; - i < widget.draftHolePoints.length - 1; - i++) + for ( + var i = 0; + i < widget.draftHolePoints.length - 1; + i++ + ) Polyline( points: [ widget.draftHolePoints[i], @@ -599,13 +788,13 @@ class _PvWorkMapState extends State { ], ), - /// 已走轨迹(绿色实线,本地按定位推送累积) - if (_trace.length >= 2) + /// 已走轨迹(作业模式由到达点与实时车位组合)。 + if (tracePoints.length >= 2) PolylineLayer( key: const ValueKey('pv_trace'), polylines: [ Polyline( - points: List.of(_trace), + points: List.of(tracePoints), color: const Color(0xFF00B42A), strokeWidth: 4, ), @@ -629,18 +818,22 @@ class _PvWorkMapState extends State { ), ], ), + /// 禁行区打点编号(已完成:红底「组号-点号」;绘制中:橙底「点号」) if (widget.draftObstacle) MarkerLayer( key: const ValueKey('pv_draft_hole_markers'), markers: [ - for (var holeIndex = 0; - holeIndex < widget.draftHoles.length; - holeIndex++) - for (var pointIndex = 0; - pointIndex < - widget.draftHoles[holeIndex].length; - pointIndex++) + for ( + var holeIndex = 0; + holeIndex < widget.draftHoles.length; + holeIndex++ + ) + for ( + var pointIndex = 0; + pointIndex < widget.draftHoles[holeIndex].length; + pointIndex++ + ) Marker( point: widget.draftHoles[holeIndex][pointIndex], width: 80, @@ -651,9 +844,11 @@ class _PvWorkMapState extends State { 8, ), ), - for (var pointIndex = 0; - pointIndex < widget.draftHolePoints.length; - pointIndex++) + for ( + var pointIndex = 0; + pointIndex < widget.draftHolePoints.length; + pointIndex++ + ) Marker( point: widget.draftHolePoints[pointIndex], width: 80, @@ -685,6 +880,53 @@ class _PvWorkMapState extends State { ), ], ), + + /// ===== 一键返航点:紫色虚线串联(末点连到准星)===== + if (widget.returnPoints.isNotEmpty) + PolylineLayer( + key: const ValueKey('pv_return_points'), + polylines: [ + for (var i = 0; i < widget.returnPoints.length - 1; i++) + Polyline( + points: [ + widget.returnPoints[i], + widget.returnPoints[i + 1], + ], + color: _returnColor.withValues(alpha: 0.85), + strokeWidth: 2, + isDotted: true, + ), + if (widget.draftCrosshair && widget.draftCenter != null) + Polyline( + points: [ + widget.returnPoints.last, + widget.draftCenter!, + ], + color: _returnColor.withValues(alpha: 0.5), + strokeWidth: 1.5, + isDotted: true, + ), + ], + ), + + /// 返航点编号:首点「H」,其余序号 + if (widget.returnPoints.isNotEmpty) + MarkerLayer( + key: const ValueKey('pv_return_point_markers'), + markers: [ + for (var i = 0; i < widget.returnPoints.length; i++) + Marker( + point: widget.returnPoints[i], + width: 80, + height: 40, + child: _draftBadge( + i == 0 ? 'H' : '${i + 1}', + _returnColor, + 10, + ), + ), + ], + ), ], ), // 还没收到定位推送时的提示(此时图面停在场站中心,机器不出现) diff --git a/lib/features/regions/presentation/widgets/no_device_crosshair_hint.dart b/lib/features/regions/presentation/widgets/no_device_crosshair_hint.dart new file mode 100644 index 00000000..d88f4d96 --- /dev/null +++ b/lib/features/regions/presentation/widgets/no_device_crosshair_hint.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class NoDeviceCrosshairHint extends StatefulWidget { + const NoDeviceCrosshairHint({super.key, required this.hasDevice}); + final bool hasDevice; + @override + State createState() => _NoDeviceCrosshairHintState(); +} + +class _NoDeviceCrosshairHintState extends State { + bool _dismissed = false; + @override + Widget build(BuildContext context) { + if (widget.hasDevice || _dismissed) return const SizedBox.shrink(); + return Material( + color: const Color(0xFFFFF6E5), + borderRadius: BorderRadius.circular(10), + elevation: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 4, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.my_location_rounded, + size: 17, + color: Color(0xFF9B6500), + ), + const SizedBox(width: 7), + const Expanded( + child: Text( + '未选择设备,请使用「准星打点」:移动地图,将准星对准目标位置后点「+」。', + style: TextStyle( + fontSize: 11.5, + color: Color(0xFF9B6500), + height: 1.4, + ), + ), + ), + InkWell( + key: const ValueKey('dismiss_crosshair_hint'), + onTap: () => setState(() => _dismissed = true), + child: const Tooltip( + message: '关闭提示', + child: Padding( + padding: EdgeInsets.all(3), + child: Icon( + Icons.close_rounded, + size: 17, + color: Color(0xFF9B6500), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart index c5ad5629..333d0ca3 100644 --- a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart @@ -57,7 +57,10 @@ class _LeftJoystickAreaState extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - '暂未收到该机器的推送,不能控制', + AppLocalizations.of( + context, + ).translate('remote_control.joystick_no_data'), + textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 14, diff --git a/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart b/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart index ad5ae442..13acdc96 100644 --- a/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart @@ -17,6 +17,17 @@ abstract class DeviceRemoteDataSource { /// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null Future getDeviceBySpiltCode(String code); + + /// 修改设备别名(只写 deviceAlias,deviceName 保持不变) + Future updateDeviceAlias({ + required String deviceId, + required String deviceAlias, + }); + + /// 一键寻车(GET /iot/host/searchDevice) + /// + /// [deviceCode] 是设备编码(deviceName,MC700- 开头),不是 getSiteList 的数字主键 id。 + Future searchDevice({required String deviceCode}); } /// 设备远程数据源实现类 - 从真实 API 获取数据 @@ -93,6 +104,8 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { for (int i = 0; i < rows.length && i < 10; i++) { debugPrint('>>> [DeviceRemoteDataSource] rows[$i]: ${rows[i]}'); } + // 同样核查一下 getAllDevices 是否下发归属字段(负责人是谁) + _logOwnershipFields(rows, source: 'getDeviceList'); return rows.map((item) => _parseDeviceFromJson(item)).toList(); } catch (e) { debugPrint('>>> [DeviceRemoteDataSource] getAllDevices error: $e'); @@ -138,6 +151,61 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { } } + @override + Future updateDeviceAlias({ + required String deviceId, + required String deviceAlias, + }) async { + final response = await dio.post( + HttpApiConsts.updateDeviceAlias, + data: {'deviceId': deviceId, 'deviceAlias': deviceAlias}, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + // 后端 data 为布尔值: true 表示别名已更新 + final updated = responseData['data'] == true; + debugPrint( + '>>> [DeviceRemoteDataSource] updateDeviceAlias deviceId=$deviceId ' + 'deviceAlias=$deviceAlias updated=$updated', + ); + return updated; + } + + @override + Future searchDevice({required String deviceCode}) async { + final response = await dio.get( + HttpApiConsts.hostSearchDevice, + queryParameters: {'deviceId': deviceCode}, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + // 兼容 int / String 类型的 code(与 createDeviceTask 同样口径) + final code = responseData['code']; + final codeValue = code is int + ? code + : int.tryParse(code?.toString() ?? ''); + if (codeValue != 200) { + throw Exception(responseData['msg'] ?? '一键寻车指令下发失败'); + } + + debugPrint( + '>>> [DeviceRemoteDataSource] searchDevice 已下发 deviceId=$deviceCode', + ); + return true; + } + Future> _fetchDevicesFromAPI(int siteId) async { final response = await dio.get( HttpApiConsts.getSiteDeviceList, @@ -155,9 +223,144 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { } final List rows = responseData['rows'] ?? []; + _logProductModel(rows, siteId: siteId); + _logOwnershipFields(rows, source: 'getSiteList'); return rows.map((item) => _parseDeviceFromJson(item)).toList(); } + /// 🔍 一次性核查:设备列表 row 里到底带不带归属字段 + /// (组织 / 场站 / 负责人),用于确认能否展示真实归属链路。 + /// 归属页靠这些字段展示真实值,拿不到就显示「未知」,不编造。 + /// 目前接入两个列表接口:getSiteList、getDeviceList。 + void _logOwnershipFields(List rows, {String source = ''}) { + if (rows.isEmpty) return; + final row = rows.first; + if (row is! Map) return; + + // 1) 先把顶层字段名全部列出:即使后端换了叫法(responsible / charge / principal) + // 也能一眼看出到底有没有「负责人」这个字段 + _printChunked( + '>>> [$source/归属字段] 顶层字段(${row.length}): ', + row.keys.join('|'), + ); + + // 2) 再按关键字把归属相关字段的“键=值”摘出来 + const keywords = [ + 'org', + 'site', + 'user', + 'owner', + 'manager', + 'responsible', + 'charge', + 'principal', + 'contact', + 'bind', + 'head', + ]; + final hits = []; + for (final entry in row.entries) { + final k = entry.key.toString().toLowerCase(); + if (keywords.any((word) => k.contains(word))) { + hits.add('${entry.key}=${entry.value}'); + } + } + debugPrint( + '>>> [$source/归属字段] 命中 ${hits.length} 个: ' + '${hits.isEmpty ? '无(该接口不下发归属信息)' : hits.join(', ')}', + ); + } + + /// 🔍 专项日志:只核查 getSiteList 返回体里是否带 productModel 字段 + /// 整体打印 row 会被截断,这里只摘取关键字段 + 型号相关候选字段 + void _logProductModel(List rows, {required int siteId}) { + debugPrint('>>> [getSiteList/datasource] siteId=$siteId 返回 ${rows.length} 条'); + if (rows.isEmpty) { + debugPrint( + '>>> [getSiteList/datasource] rows 为空,无法判断 productModel 是否存在', + ); + return; + } + for (int i = 0; i < rows.length; i++) { + final row = rows[i]; + if (row is! Map) { + debugPrint( + '>>> [getSiteList/datasource] rows[$i] 非 Map 结构: ${row.runtimeType}', + ); + continue; + } + final json = Map.from(row); + final head = + 'deviceId=${json['deviceId']} name=${json['deviceName'] ?? json['name']}'; + if (json.containsKey('productModel')) { + final value = json['productModel']; + debugPrint( + '>>> [getSiteList/datasource] rows[$i] $head 有 productModel=$value (${value.runtimeType})', + ); + } else { + final candidates = json.entries + .where((e) => _isModelLikeKey(e.key)) + .map((e) => '${e.key}=${e.value}') + .join(', '); + debugPrint( + '>>> [getSiteList/datasource] rows[$i] $head 无 productModel 字段;型号候选字段: ' + '${candidates.isEmpty ? '无' : candidates}', + ); + // 彻底核实:打印顶层全部字段名,并递归扫描子对象/数组, + // 确认后端是否在任意层级返回过 productModel + _printChunked( + '>>> [getSiteList/datasource] rows[$i] 全部顶层字段(${json.length}): ', + json.keys.join('|'), + ); + final nested = _findKeyPaths(json, 'productModel'); + debugPrint( + '>>> [getSiteList/datasource] rows[$i] 嵌套层 productModel 命中: ' + '${nested.isEmpty ? '无(子对象/数组均未找到)' : nested.join(', ')}', + ); + } + } + } + + /// 递归查找指定 key(含子对象与数组),返回“路径=值”列表 + List _findKeyPaths(dynamic node, String key, [String path = '']) { + final hits = []; + if (node is Map) { + node.forEach((k, v) { + final p = path.isEmpty ? '$k' : '$path.$k'; + if (k.toString() == key) { + hits.add('$p=$v'); + } else { + hits.addAll(_findKeyPaths(v, key, p)); + } + }); + } else if (node is List) { + for (int i = 0; i < node.length; i++) { + hits.addAll(_findKeyPaths(node[i], key, '$path[$i]')); + } + } + return hits; + } + + /// 长文本分段打印,避免 debugPrint 单条过长被截断 + void _printChunked(String prefix, String content) { + const chunk = 700; + var head = prefix; + for (int start = 0; start < content.length; start += chunk) { + final end = + start + chunk < content.length ? start + chunk : content.length; + debugPrint('$head${content.substring(start, end)}'); + head = ' ↳ '; + } + } + + bool _isModelLikeKey(String key) { + final k = key.toLowerCase(); + return k.contains('model') || + k.contains('product') || + k.contains('type') || + k.contains('version'); + } + Future> _fetchUAVDevicesFromAPI(int siteId) async { final response = await dio.get( HttpApiConsts.getSiteUAVList, @@ -202,6 +405,11 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { voltage: (json['voltage'] as num?)?.toDouble(), radiation: (json['radiation'] as num?)?.toDouble(), isRobot: isRobot, + // 型号:列表每一项都展示(productModel 优先,productName 兜底) + productName: (json['productName'] ?? '').toString(), + productModel: (json['productModel'] ?? '').toString().trim().isEmpty + ? null + : json['productModel'].toString().trim(), ); } @@ -306,7 +514,7 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { status: '异常', power: 45.2, todayEnergy: 256.8, - ), + ), ]; if (typeFilter != null && typeFilter != 'all') { diff --git a/lib/features/v2/device_list/data/repositories/device_repository_impl.dart b/lib/features/v2/device_list/data/repositories/device_repository_impl.dart index e1dddc05..54a1e5ff 100644 --- a/lib/features/v2/device_list/data/repositories/device_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/device_repository_impl.dart @@ -1,3 +1,7 @@ +import 'package:dio/dio.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; + import '../../domain/entities/device_entity.dart'; import '../../domain/entities/device_status_entity.dart'; import '../../domain/repositories/device_repository.dart'; @@ -32,4 +36,42 @@ class DeviceRepositoryImpl implements DeviceRepository { final dataModel = await remoteDataSource.getDeviceBySpiltCode(code); return dataModel?.toEntity(); } + + @override + Future> updateDeviceAlias({ + required String deviceId, + required String deviceAlias, + }) async { + try { + final updated = await remoteDataSource.updateDeviceAlias( + deviceId: deviceId, + deviceAlias: deviceAlias, + ); + return Right(updated); + } on DioException catch (e) { + final serverMessage = e.response?.data is Map + ? (e.response?.data['msg'] ?? '网络连接异常') + : '网络连接异常'; + return Left(Failure('$serverMessage')); + } catch (e) { + final cleanMessage = e.toString().replaceFirst('Exception: ', ''); + return Left(Failure(cleanMessage)); + } + } + + @override + Future> searchDevice({required String deviceCode}) async { + try { + final sent = await remoteDataSource.searchDevice(deviceCode: deviceCode); + return Right(sent); + } on DioException catch (e) { + final serverMessage = e.response?.data is Map + ? (e.response?.data['msg'] ?? '网络连接异常') + : '网络连接异常'; + return Left(Failure('$serverMessage')); + } catch (e) { + final cleanMessage = e.toString().replaceFirst('Exception: ', ''); + return Left(Failure(cleanMessage)); + } + } } diff --git a/lib/features/v2/device_list/domain/repositories/device_repository.dart b/lib/features/v2/device_list/domain/repositories/device_repository.dart index 2e3fd3b0..f7629be8 100644 --- a/lib/features/v2/device_list/domain/repositories/device_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/device_repository.dart @@ -1,3 +1,6 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; + import '../entities/device_entity.dart'; import '../entities/device_status_entity.dart'; @@ -9,4 +12,19 @@ abstract class DeviceRepository { /// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null Future getDeviceBySpiltCode(String code); + + /// 修改设备别名。 + /// + /// 后端只更新 deviceAlias,deviceName(出厂序列号)保持不变; + /// 返回 true 表示别名已成功写库。 + Future> updateDeviceAlias({ + required String deviceId, + required String deviceAlias, + }); + + /// 一键寻车:向后端下发寻车指令(GET /iot/host/searchDevice)。 + /// + /// [deviceCode] 是设备编码(MC700- 开头),不是数字主键; + /// 返回 true 表示指令已成功下发。 + Future> searchDevice({required String deviceCode}); } diff --git a/lib/features/v2/device_list/presentation/cubit/find_robot_cubit.dart b/lib/features/v2/device_list/presentation/cubit/find_robot_cubit.dart new file mode 100644 index 00000000..4af0dc14 --- /dev/null +++ b/lib/features/v2/device_list/presentation/cubit/find_robot_cubit.dart @@ -0,0 +1,48 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../domain/usecases/search_device_usecase.dart'; +import 'find_robot_state.dart'; + +/// 一键寻车 Cubit +/// +/// 调用链:UI → FindRobotCubit → SearchDeviceUseCase → Repository → DataSource, +/// 弹窗不再直接依赖用例或数据源。 +/// +/// 下发口径:deviceId 传设备编码(deviceName,MC700- 开头),不是数字主键 id。 +class FindRobotCubit extends Cubit { + final SearchDeviceUseCase _searchDevice; + + FindRobotCubit(this._searchDevice) : super(const FindRobotState()); + + /// 下发一键寻车指令(GET /iot/host/searchDevice) + /// + /// [deviceCode] 为空时直接判定失败,不发请求(避免后端把空串当全量)。 + Future startFind({required String deviceCode}) async { + if (state.isSubmitting) return; + + final code = deviceCode.trim(); + if (code.isEmpty) { + emit(const FindRobotState(errorMessage: '')); + return; + } + + emit(FindRobotState(isSubmitting: true, deviceCode: code)); + + final result = await _searchDevice.execute(deviceCode: code); + + result.fold( + (failure) => emit( + FindRobotState(errorMessage: failure.message, deviceCode: code), + ), + (sent) => emit( + FindRobotState(isSuccess: sent, deviceCode: code), + ), + ); + } + + /// 结果已被界面消费,复位避免重复弹提示 + void reset() { + if (state.isSubmitting) return; + emit(const FindRobotState()); + } +} diff --git a/lib/features/v2/device_list/presentation/cubit/find_robot_state.dart b/lib/features/v2/device_list/presentation/cubit/find_robot_state.dart new file mode 100644 index 00000000..f734f0d9 --- /dev/null +++ b/lib/features/v2/device_list/presentation/cubit/find_robot_state.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +/// 一键寻车指令下发状态 +/// +/// 一次性结果用 [isSuccess] / [errorMessage] 表达: +/// [errorMessage] 非 null 即失败,空串表示只知失败、文案由 UI 套本地化提示。 +class FindRobotState extends Equatable { + /// 请求进行中(用于拦截重复点击) + final bool isSubmitting; + + /// 后端已成功下发寻车指令 + final bool isSuccess; + + /// 失败原因;null 表示尚无结果 + final String? errorMessage; + + /// 本次操作的设备编码(MC700- 开头) + final String? deviceCode; + + const FindRobotState({ + this.isSubmitting = false, + this.isSuccess = false, + this.errorMessage, + this.deviceCode, + }); + + @override + List get props => [isSubmitting, isSuccess, errorMessage, deviceCode]; +} diff --git a/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart b/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart index 8b454fec..84c3dcbc 100644 --- a/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart +++ b/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart @@ -5,7 +5,10 @@ import 'package:shared_preferences/shared_preferences.dart'; /// 使用静态变量存储状态,确保全局同步 class FloatBarSettingService { final SharedPreferences _prefs; - static const String _key = 'float_bar_enabled'; + + /// key 升版:旧版(float_bar_enabled)可能残留 true,导致升级后悬浮条依然显示, + /// 故换用新 key,保证首次启动一律回到默认关闭。 + static const String _key = 'float_bar_enabled_v2'; /// 🔥 静态实例引用 static FloatBarSettingService? _instance; @@ -18,6 +21,7 @@ class FloatBarSettingService { FloatBarSettingService(this._prefs) { _instance = this; + // 默认关闭:未设置过时一律不显示悬浮条 _isEnabled = _prefs.getBool(_key) ?? false; _settingNotifier.value = _isEnabled; debugPrint('✅ [FloatBarSettingService] 初始化完成,初始状态: $_isEnabled'); diff --git a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart index 367a78b1..6bab45e5 100644 --- a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart +++ b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart @@ -18,6 +18,7 @@ import '../../../../../../core/di/injection.dart'; import '../../../../../../core/managers/drone_task_state_manager.dart'; import '../../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart'; import '../../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/app_base_map_layer.dart'; /// 模拟轨迹模式枚举 enum SimulationMode { @@ -1501,14 +1502,8 @@ class FloatBarWidgetState extends State { }, ), children: [ - // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) - // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 - TileLayer( - urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - userAgentPackageName: 'com.example.app', - minZoom: 0, - maxZoom: 19, - ), + // 底图:中文=ESRI,非中文=Google(不可达自动回退 ESRI) + const AppBaseMapLayer(), // 轨迹线(中间层) if (_trajectoryPoints.length >= 1) diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 35a9255b..2582a2c7 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -781,31 +781,33 @@ class _DeviceStatusViewState extends State { '🔴🔴🔴 [全部-选中机器人] hasHost: ${robot.hasHost}', ); - // 🔥 hasHost 字段判定:false → 蓝牙连接流程 - if (!robot.hasHost) { - debugPrint('⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程'); - - // 从设备名中提取时间戳(如 MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120) - final timestamp = _extractTimestamp(robot.name); - if (timestamp == null || timestamp.isEmpty) { - debugPrint('❌ [全部-EmbeddedRobotList] 未提取到时间戳'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('设备名称异常,暂时无法连接'), - duration: Duration(seconds: 2), - ), - ); - return; - } - - debugPrint('✅ [全部-EmbeddedRobotList] 提取到时间戳: $timestamp'); - - // 调用蓝牙连接流程 - _connectToDeviceViaBluetooth(context, robot.name, timestamp); - return; - } + // ===== 【暂时停用】hasHost 分层判定 ===== + // 需求:暂时不管有无宿主,点卡片一律直接进控制页。 + // 恢复方式:取消下面整段注释即可(原逻辑:hasHost=false → 蓝牙连接流程) + // if (!robot.hasHost) { + // debugPrint('⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程'); + // + // // 从设备名中提取时间戳(如 MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120) + // final timestamp = _extractTimestamp(robot.name); + // if (timestamp == null || timestamp.isEmpty) { + // debugPrint('❌ [全部-EmbeddedRobotList] 未提取到时间戳'); + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text('设备名称异常,暂时无法连接'), + // duration: Duration(seconds: 2), + // ), + // ); + // return; + // } + // + // debugPrint('✅ [全部-EmbeddedRobotList] 提取到时间戳: $timestamp'); + // + // // 调用蓝牙连接流程 + // _connectToDeviceViaBluetooth(context, robot.name, timestamp); + // return; + // } - // hasHost == true → 走原来的逻辑 + // 原 hasHost == true 分支(现在所有设备都走这里) debugPrint('✅ [全部-EmbeddedRobotList] hasHost=true,走正常控制流程'); // 1. 将当前机器人设置为全局待控制设备 diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart b/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart index 609c54f5..d58a6796 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page_v2.dart @@ -47,7 +47,8 @@ import '../../../local_camera/presentation/pages/local_camera_page.dart'; import '../../../usb_camera/presentation/pages/usb_camera_page.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; import '../../../../devices/domain/entities/device_entity.dart'; -import '../../../../devices/domain/usecases/update_devicename_usecase.dart'; +import '../cubit/rename_device_cubit.dart'; +import '../cubit/rename_device_state.dart'; import 'package:get_it/get_it.dart'; /// 设备状态页面 V2 —— 按「02_设备卡片总览」设计稿换皮。 @@ -63,10 +64,16 @@ class DeviceStatusPageV2 extends StatelessWidget { final selectedSite = sl().state.selectedSite; final siteId = selectedSite?.id; - return BlocProvider( - create: (_) => - sl() - ..add(DeviceListEvent.DeviceStatusLoadData(siteId: siteId)), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + sl() + ..add(DeviceListEvent.DeviceStatusLoadData(siteId: siteId)), + ), + // 设备改名:UI 只调 Cubit,不直接依赖 UseCase/DataSource + BlocProvider(create: (_) => sl()), + ], child: const DeviceStatusViewV2(), ); } @@ -355,7 +362,10 @@ class _DeviceStatusViewV2State extends State { } }, builder: (context, stationState) => - _buildV2Scaffold(context, robotState, stationState), + BlocListener( + listener: _onRenameState, + child: _buildV2Scaffold(context, robotState, stationState), + ), ), ), ); @@ -408,8 +418,11 @@ class _DeviceStatusViewV2State extends State { for (final r in robotState.robots) { items.add( _V2DeviceItem( + // 展示口径:deviceAlias 为空(首次绑定必为 null)就回退 deviceName; + // 后端改名接口只写 deviceAlias,deviceName 永远保持不变 name: (r.alias != null && r.alias!.isNotEmpty) ? r.alias! : r.name, - modelText: '${r.type} · ${DeviceV2Display.editionOf(r.id)}', + // 型号 · 版本:由 getSiteList 的 productModel 解析(如 MC700 · PRO 旗舰版) + modelText: r.modelLabelOf(AppLocalizations.of(context)), snKey: r.id, status: _robotV2Status(r), battery: r.isOnline ? r.battery.round() : null, @@ -424,7 +437,12 @@ class _DeviceStatusViewV2State extends State { items.add( _V2DeviceItem( name: s.callsign.isNotEmpty ? s.callsign : sn, - modelText: '无人机机场 · ${DeviceV2Display.editionOf(sn)}', + modelText: AppLocalizations.of(context) + .translate('device_list_v2.drone_station_model') + .replaceAll( + '%s', + DeviceV2Display.editionOf(sn, AppLocalizations.of(context)), + ), snKey: sn, status: s.isOnline ? DeviceV2Status.online : DeviceV2Status.offline, battery: s.isOnline ? s.capacityPercent?.round() : null, @@ -536,13 +554,18 @@ class _DeviceStatusViewV2State extends State { ), ) else if (visible.isEmpty) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(40), + padding: const EdgeInsets.all(40), child: Center( child: Text( - '没有匹配的设备', - style: TextStyle(fontSize: 13, color: _v2Gray), + AppLocalizations.of(context).translate( + 'device_list_v2.no_matching_device', + ), + style: const TextStyle( + fontSize: 13, + color: _v2Gray, + ), ), ), ), @@ -591,25 +614,46 @@ class _DeviceStatusViewV2State extends State { } /// 卡片右上角「...」:进「设备归属与安全」页(仅机器人有归属关系;机场暂未开发) - void _v2ItemMore(BuildContext context, _V2DeviceItem item) { + Future _v2ItemMore(BuildContext context, _V2DeviceItem item) async { final robot = item.robot; if (robot == null) { - CapsuleToast.show('功能暂未开发', showCheck: false); + CapsuleToast.show( + AppLocalizations.of(context).translate('device_list_v2.feature_todo'), + showCheck: false, + ); return; } - Navigator.push( + // 归属页内完成解绑后会回传 true,此时卡片与统计卡都要重拉 + // 当前选中场站一并传过去:设备就是从这个场站查出来的, + // 后端未下发设备归属时用于兜底展示真实场站与组织 + final site = sl().state.selectedSite; + final unbound = await Navigator.push( context, MaterialPageRoute( - builder: (context) => RobotOwnershipPageV2(robot: robot), + builder: (context) => RobotOwnershipPageV2( + robot: robot, + siteId: site?.id, + siteName: site?.siteName, + orgId: site?.orgId, + ), ), ); + if (unbound == true && context.mounted) { + context.read().add(const RobotListRefresh()); + context.read().add( + const DeviceListEvent.DeviceStatusRefresh(), + ); + } } - /// 卡片铅笔图标:修改设备名称(弹窗只负责输入,下发走真实 updateDeviceAlias) + /// 卡片铅笔图标:只负责收集输入,下发交给 RenameDeviceCubit Future _v2ItemRename(BuildContext context, _V2DeviceItem item) async { final robot = item.robot; if (robot == null) { - CapsuleToast.show('功能暂未开发', showCheck: false); + CapsuleToast.show( + AppLocalizations.of(context).translate('device_list_v2.feature_todo'), + showCheck: false, + ); return; } final newName = await showRenameDeviceDialog( @@ -621,33 +665,51 @@ class _DeviceStatusViewV2State extends State { // 取消 / 名称未变(与旧页一致:不调接口直接返回) if (newName == null || newName == item.name || !context.mounted) return; - final result = await sl()( - UpdateDevicenameParams(robot.id, newName), + // 后端 updateDeviceAlias 的设备标识用设备编码(deviceName,MC700- 开头), + // 不是 getSiteList 返回的数字主键 robot.id;deviceName 异常为空时回退 id 兜底 + final targetDeviceId = robot.name.isNotEmpty ? robot.name : robot.id; + debugPrint( + '>>> [V2-Rename] 下发 deviceId=$targetDeviceId alias=$newName (robot.id=${robot.id})', ); - if (!context.mounted) return; - - result.fold( - (failure) => CapsuleToast.show( - failure.message.isEmpty ? '修改设备名称失败' : failure.message, - showCheck: false, - ), - (code) { - if (code != 1) { - CapsuleToast.show('修改设备名称失败:状态码 $code', showCheck: false); - return; - } - CapsuleToast.show('设备名称已更新为「$newName」'); - // 刷新卡片列表(别名优先展示,重拉后即可看到新名称) - context.read().add(const RobotListRefresh()); - context.read().add( - const DeviceListEvent.DeviceStatusRefresh(), - ); - }, + context.read().rename( + deviceId: targetDeviceId, + alias: newName, ); } + /// 改名结果回调:弹提示 + 重拉列表(deviceAlias 写入后卡片即显示新名称) + void _onRenameState(BuildContext context, RenameDeviceState state) { + // 提交中无 toast 需求 + if (state.isSubmitting) return; + + final l10n = AppLocalizations.of(context); + + if (state.isSuccess) { + CapsuleToast.show( + l10n + .translate('device_list_v2.rename_success') + .replaceAll('%s', state.alias ?? ''), + ); + // 卡片列表与统计卡是两套数据源,都要重拉 + context.read().add(const RobotListRefresh()); + context.read().add( + const DeviceListEvent.DeviceStatusRefresh(), + ); + context.read().reset(); + return; + } + + final message = state.errorMessage; + if (message == null) return; + CapsuleToast.show( + message.isEmpty ? l10n.translate('device_list_v2.rename_failed') : message, + showCheck: false, + ); + context.read().reset(); + } + /// 卡片点击分发:与旧列表 onTap 逐行一致(机器人→hasHost 流程,机场→详情页) - void _v2ItemTap(BuildContext context, _V2DeviceItem item) { + Future _v2ItemTap(BuildContext context, _V2DeviceItem item) async { final robot = item.robot; if (robot != null) { debugPrint( @@ -655,29 +717,31 @@ class _DeviceStatusViewV2State extends State { ); debugPrint('🔴🔴🔴 [V2-选中机器人] hasHost: ${robot.hasHost}'); - // 🔥 hasHost 字段判定:false → 蓝牙连接流程 - if (!robot.hasHost) { - debugPrint('⚠️ [V2] hasHost=false,进入蓝牙连接流程'); + // ===== 【暂时停用】hasHost 分层判定 ===== + // 需求:暂时不管有无宿主,点卡片一律直接进设备详情页。 + // 恢复方式:取消下面整段注释即可(原逻辑:hasHost=false → 蓝牙连接流程) + // if (!robot.hasHost) { + // debugPrint('⚠️ [V2] hasHost=false,进入蓝牙连接流程'); + // + // // 从设备名中提取时间戳 + // final timestamp = _extractTimestamp(robot.name); + // if (timestamp == null || timestamp.isEmpty) { + // debugPrint('❌ [V2] 未提取到时间戳'); + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text('设备名称异常,暂时无法连接'), + // duration: Duration(seconds: 2), + // ), + // ); + // return; + // } + // + // debugPrint('✅ [V2] 提取到时间戳: $timestamp'); + // _connectToDeviceViaBluetooth(context, robot.name, timestamp); + // return; + // } - // 从设备名中提取时间戳 - final timestamp = _extractTimestamp(robot.name); - if (timestamp == null || timestamp.isEmpty) { - debugPrint('❌ [V2] 未提取到时间戳'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('设备名称异常,暂时无法连接'), - duration: Duration(seconds: 2), - ), - ); - return; - } - - debugPrint('✅ [V2] 提取到时间戳: $timestamp'); - _connectToDeviceViaBluetooth(context, robot.name, timestamp); - return; - } - - // hasHost == true → 走原来的逻辑:前置(setTargetDevice)不变, + // 原 hasHost == true 分支(现在所有设备都走这里):前置(setTargetDevice)不变, // 跳转目标换成新设备详情页(V2 概览设计稿); // 页内铃铛/远程遥控再回旧 RobotControlPage,方便新旧对照。 debugPrint('✅ [V2] hasHost=true,走正常控制流程'); @@ -695,12 +759,19 @@ class _DeviceStatusViewV2State extends State { final remoteCubit = GetIt.I(); remoteCubit.setTargetDevice(device); - Navigator.push( + final renamed = await Navigator.push( context, MaterialPageRoute( builder: (context) => DeviceDetailPageV2(robot: robot), ), ); + // 详情页改过名:卡片与统计卡都要重拉,否则列表还显示旧名字 + if (renamed == true && context.mounted) { + context.read().add(const RobotListRefresh()); + context.read().add( + const DeviceListEvent.DeviceStatusRefresh(), + ); + } return; } @@ -777,7 +848,10 @@ class _DeviceStatusViewV2State extends State { } void _v2Todo() { - CapsuleToast.show('功能暂未开发', showCheck: false); + CapsuleToast.show( + AppLocalizations.of(context).translate('device_list_v2.feature_todo'), + showCheck: false, + ); } // ===== 切换场站入口:右上角按钮下方小字(场站名) ===== @@ -788,7 +862,10 @@ class _DeviceStatusViewV2State extends State { return BlocBuilder( bloc: sl(), builder: (context, siteState) { - final name = siteState.selectedSite?.siteName ?? '未选择场站'; + final l10n = AppLocalizations.of(context); + final name = + siteState.selectedSite?.siteName ?? + l10n.translate('device_list_v2.no_site_selected'); return Align( alignment: Alignment.centerRight, child: GestureDetector( @@ -800,7 +877,13 @@ class _DeviceStatusViewV2State extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - isPersonal ? '($name)· 个人账号' : '($name)', + isPersonal + ? l10n + .translate('device_list_v2.site_suffix_personal') + .replaceAll('%s', name) + : l10n + .translate('device_list_v2.site_suffix') + .replaceAll('%s', name), style: const TextStyle(fontSize: 12, color: _v2Gray), ), if (!isPersonal) @@ -823,12 +906,19 @@ class _DeviceStatusViewV2State extends State { SiteState siteState, bool isPersonal, ) { + final l10n = AppLocalizations.of(context); if (isPersonal) { - CapsuleToast.show('个人账号仅绑定一个场站', showCheck: false); + CapsuleToast.show( + l10n.translate('device_list_v2.personal_single_site'), + showCheck: false, + ); return; } if (siteState.sites.isEmpty) { - CapsuleToast.show('暂无场站数据', showCheck: false); + CapsuleToast.show( + l10n.translate('device_list_v2.no_site_data'), + showCheck: false, + ); return; } final siteCubit = sl(); @@ -843,15 +933,19 @@ class _DeviceStatusViewV2State extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 14), - child: Center( - child: Text( - '选择场站', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: _v2Navy, + Builder( + builder: (sheetCtx) => Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Center( + child: Text( + AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.select_site'), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _v2Navy, + ), ), ), ), @@ -897,6 +991,7 @@ class _DeviceStatusViewV2State extends State { // ===== 标题区 ===== Widget _buildV2Header() { + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), // SizedBox 撑满宽:否则 Column 会收缩到文本宽并被外层 Column 居中, @@ -905,19 +1000,19 @@ class _DeviceStatusViewV2State extends State { width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ Text( - '我的机器人', - style: TextStyle( + l10n.translate('device_list_v2.my_robots'), + style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: _v2Navy, ), ), - SizedBox(height: 6), + const SizedBox(height: 6), Text( - '多机型统一管理 · 按设备 SN 动态加载功能', - style: TextStyle(fontSize: 13, color: _v2Gray), + l10n.translate('device_list_v2.my_robots_desc'), + style: const TextStyle(fontSize: 13, color: _v2Gray), ), ], ), @@ -928,6 +1023,7 @@ class _DeviceStatusViewV2State extends State { // ===== 卡片视图 / 集中概览 切换(03 设计稿:浅蓝轨道 + 白色选中胶囊) ===== Widget _buildViewToggle() { final isCard = _viewMode == 'card'; + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Container( @@ -941,7 +1037,7 @@ class _DeviceStatusViewV2State extends State { children: [ Expanded( child: _buildToggleSegment( - '卡片视图', + l10n.translate('device_list_v2.view_card'), Icons.grid_view_rounded, isCard, () => setState(() => _viewMode = 'card'), @@ -949,7 +1045,7 @@ class _DeviceStatusViewV2State extends State { ), Expanded( child: _buildToggleSegment( - '集中概览', + l10n.translate('device_list_v2.view_overview'), Icons.bar_chart_rounded, !isCard, () => setState(() => _viewMode = 'overview'), @@ -1018,14 +1114,19 @@ class _DeviceStatusViewV2State extends State { borderRadius: BorderRadius.circular(14), border: Border.all(color: _v2Border), ), - child: const Row( + child: Row( children: [ - SizedBox(width: 14), - Icon(Icons.search, size: 20, color: Color(0xFFC9CDD4)), - SizedBox(width: 10), + const SizedBox(width: 14), + const Icon(Icons.search, size: 20, color: Color(0xFFC9CDD4)), + const SizedBox(width: 10), Text( - '搜索设备名称、型号或 SN', - style: TextStyle(fontSize: 14, color: Color(0xFFB9C2CF)), + AppLocalizations.of( + context, + ).translate('device_list_v2.search_hint_full'), + style: const TextStyle( + fontSize: 14, + color: Color(0xFFB9C2CF), + ), ), ], ), @@ -1036,11 +1137,12 @@ class _DeviceStatusViewV2State extends State { // ===== 状态过滤药丸:真实生效 ===== Widget _buildStatusPills(int all, int online, int working, int offline) { + final l10n = AppLocalizations.of(context); final pills = [ - ('all', '全部', all), - ('online', '在线', online), - ('working', '作业中', working), - ('offline', '离线', offline), + ('all', l10n.translate('device_list_v2.all'), all), + ('online', l10n.translate('device_list_v2.online'), online), + ('working', l10n.translate('device_list_v2.stat_working'), working), + ('offline', l10n.translate('device_list_v2.offline'), offline), ]; return Padding( padding: const EdgeInsets.fromLTRB(0, 10, 0, 2), @@ -1181,9 +1283,11 @@ class _DeviceStatusViewV2State extends State { ), Expanded( child: Text( - '设备集中概览', + AppLocalizations.of( + context, + ).translate('device_list_v2.overview_title'), textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: _v2Navy, @@ -1197,25 +1301,26 @@ class _DeviceStatusViewV2State extends State { } Widget _buildOverviewHeader() { + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), child: SizedBox( width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ Text( - '设备集中概览', - style: TextStyle( + l10n.translate('device_list_v2.overview_title'), + style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: _v2Navy, ), ), - SizedBox(height: 6), + const SizedBox(height: 6), Text( - '适合企业多机器人集中管理', - style: TextStyle(fontSize: 13, color: _v2Gray), + l10n.translate('device_list_v2.overview_desc'), + style: const TextStyle(fontSize: 13, color: _v2Gray), ), ], ), @@ -1225,11 +1330,27 @@ class _DeviceStatusViewV2State extends State { /// 四张统计卡:设备总数 / 在线设备 / 作业中 / 告警离线 Widget _buildStatCards(int all, int online, int working, int offline) { + final l10n = AppLocalizations.of(context); final stats = [ - (Icons.smart_toy_rounded, _v2Blue, all, '设备总数'), - (Icons.wifi_rounded, _v2Blue, online, '在线设备'), - (Icons.play_circle_fill_rounded, const Color(0xFF2FBF6B), working, '作业中'), - (Icons.warning_amber_rounded, const Color(0xFFE5484D), offline, '告警/离线'), + (Icons.smart_toy_rounded, _v2Blue, all, l10n.translate('device_list_v2.stat_total')), + ( + Icons.wifi_rounded, + _v2Blue, + online, + l10n.translate('device_list_v2.stat_online_devices'), + ), + ( + Icons.play_circle_fill_rounded, + const Color(0xFF2FBF6B), + working, + l10n.translate('device_list_v2.stat_working'), + ), + ( + Icons.warning_amber_rounded, + const Color(0xFFE5484D), + offline, + l10n.translate('device_list_v2.stat_alarm_offline'), + ), ]; return Row( children: [ @@ -1285,9 +1406,11 @@ class _DeviceStatusViewV2State extends State { padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), child: Row( children: [ - const Text( - '设备分布', - style: TextStyle( + Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.device_distribution'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: _v2Navy, @@ -1300,7 +1423,11 @@ class _DeviceStatusViewV2State extends State { child: Row( children: [ Text( - '共 $all 台设备', + AppLocalizations.of( + context, + ) + .translate('device_list_v2.total_devices_count') + .replaceAll('%s', '$all'), style: const TextStyle(fontSize: 12, color: _v2Blue), ), const Icon(Icons.chevron_right, size: 16, color: _v2Blue), @@ -1331,48 +1458,58 @@ class _DeviceStatusViewV2State extends State { ), child: Column( children: [ - const Padding( - padding: EdgeInsets.fromLTRB(14, 12, 14, 8), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), child: Row( children: [ Expanded( flex: 5, child: Text( - '设备信息', - style: TextStyle(fontSize: 12, color: _v2Gray), + AppLocalizations.of( + context, + ).translate('device_list_v2.th_device_info'), + style: const TextStyle(fontSize: 12, color: _v2Gray), ), ), Expanded( flex: 3, child: Text( - '状态', - style: TextStyle(fontSize: 12, color: _v2Gray), + AppLocalizations.of( + context, + ).translate('device_list_v2.th_status'), + style: const TextStyle(fontSize: 12, color: _v2Gray), ), ), Expanded( flex: 2, child: Text( - '电量', - style: TextStyle(fontSize: 12, color: _v2Gray), + AppLocalizations.of( + context, + ).translate('device_list_v2.battery_level'), + style: const TextStyle(fontSize: 12, color: _v2Gray), ), ), Expanded( flex: 4, child: Text( - '位置/当前任务', - style: TextStyle(fontSize: 12, color: _v2Gray), + AppLocalizations.of( + context, + ).translate('device_list_v2.th_location_task'), + style: const TextStyle(fontSize: 12, color: _v2Gray), ), ), - SizedBox(width: 16), + const SizedBox(width: 16), ], ), ), if (visible.isEmpty) - const Padding( - padding: EdgeInsets.all(32), + Padding( + padding: const EdgeInsets.all(32), child: Text( - '没有匹配的设备', - style: TextStyle(fontSize: 13, color: _v2Gray), + AppLocalizations.of( + context, + ).translate('device_list_v2.no_matching_device'), + style: const TextStyle(fontSize: 13, color: _v2Gray), ), ) else @@ -1417,7 +1554,8 @@ class _DeviceStatusViewV2State extends State { ), const SizedBox(height: 2), Text( - '${DeviceV2Display.versionOf(e.snKey)} SN: ${DeviceV2Display.maskedSn(e.snKey)}', + // 概览表第二行:型号 · 版本 + SN + '${e.modelText} SN: ${DeviceV2Display.maskedSn(e.snKey)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10, color: _v2Gray), @@ -1444,8 +1582,11 @@ class _DeviceStatusViewV2State extends State { flex: 4, child: Text( e.robot != null - ? '${DeviceV2Display.locationOf(e.snKey)} · ${e.robot!.task}' - : DeviceV2Display.locationOf(e.snKey), + ? '${DeviceV2Display.locationOf(e.snKey, AppLocalizations.of(context))} · ${e.robot!.task}' + : DeviceV2Display.locationOf( + e.snKey, + AppLocalizations.of(context), + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: _v2Gray), @@ -1459,21 +1600,22 @@ class _DeviceStatusViewV2State extends State { } Widget _buildOverviewBadge(DeviceV2Status status) { + final l10n = AppLocalizations.of(context); final (label, fg, bg, icon) = switch (status) { DeviceV2Status.working => ( - '作业中', + l10n.translate('device_list_v2.stat_working'), const Color(0xFF2FBF6B), const Color(0xFFE5F8EC), Icons.play_arrow_rounded, ), DeviceV2Status.online => ( - '在线', + l10n.translate('device_list_v2.online'), _v2Blue, const Color(0xFFE7F3FE), Icons.wifi_rounded, ), DeviceV2Status.offline => ( - '离线', + l10n.translate('device_list_v2.offline'), const Color(0xFF6B7280), const Color(0xFFF0F2F5), Icons.pause_rounded, @@ -1548,11 +1690,13 @@ class _DeviceStatusViewV2State extends State { padding: const EdgeInsets.fromLTRB(16, 14, 8, 4), child: Row( children: [ - const Expanded( + Expanded( child: Center( child: Text( - '添加机器人', - style: TextStyle( + AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.add_robot'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: _v2Navy, @@ -1569,7 +1713,9 @@ class _DeviceStatusViewV2State extends State { ), _v2SheetItem( icon: Icons.qr_code_scanner, - label: '扫一扫', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.scan_action'), onTap: () async { Navigator.pop(sheetCtx); final result = await Navigator.of(context).push( @@ -1594,7 +1740,9 @@ class _DeviceStatusViewV2State extends State { const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)), _v2SheetItem( icon: Icons.add_circle_outline, - label: '添加设备', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.add_device'), onTap: () { Navigator.pop(sheetCtx); // 原菜单该项即为 TODO,保持不跳转 @@ -1604,7 +1752,9 @@ class _DeviceStatusViewV2State extends State { const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)), _v2SheetItem( icon: Icons.videocam_outlined, - label: '本地视频', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.local_video'), onTap: () { Navigator.pop(sheetCtx); Navigator.of(context).push( @@ -1615,7 +1765,9 @@ class _DeviceStatusViewV2State extends State { const Divider(height: 1, indent: 20, color: Color(0xFFF0F4FA)), _v2SheetItem( icon: Icons.usb, - label: 'USB 摄像头', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.usb_camera'), onTap: () { Navigator.pop(sheetCtx); Navigator.of(context).push( @@ -2168,6 +2320,7 @@ class _DeviceStatusViewV2State extends State { id: robot.id, alias: robot.alias, type: robot.type, + modelText: robot.modelLabelOf(AppLocalizations.of(context)), status: robot.status, battery: robot.battery, task: robot.task, @@ -2177,39 +2330,41 @@ class _DeviceStatusViewV2State extends State { ); debugPrint('🔴🔴🔴 [全部-选中机器人] hasHost: ${robot.hasHost}'); - // 🔥 hasHost 字段判定:false → 蓝牙连接流程 - if (!robot.hasHost) { - debugPrint( - '⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程', - ); + // ===== 【暂时停用】hasHost 分层判定 ===== + // 需求:暂时不管有无宿主,点卡片一律直接进设备详情页。 + // 恢复方式:取消下面整段注释即可(原逻辑:hasHost=false → 蓝牙连接流程) + // if (!robot.hasHost) { + // debugPrint( + // '⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程', + // ); + // + // // 从设备名中提取时间戳(如 MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120) + // final timestamp = _extractTimestamp(robot.name); + // if (timestamp == null || timestamp.isEmpty) { + // debugPrint('❌ [全部-EmbeddedRobotList] 未提取到时间戳'); + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text('设备名称异常,暂时无法连接'), + // duration: Duration(seconds: 2), + // ), + // ); + // return; + // } + // + // debugPrint( + // '✅ [全部-EmbeddedRobotList] 提取到时间戳: $timestamp', + // ); + // + // // 调用蓝牙连接流程 + // _connectToDeviceViaBluetooth( + // context, + // robot.name, + // timestamp, + // ); + // return; + // } - // 从设备名中提取时间戳(如 MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120) - final timestamp = _extractTimestamp(robot.name); - if (timestamp == null || timestamp.isEmpty) { - debugPrint('❌ [全部-EmbeddedRobotList] 未提取到时间戳'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('设备名称异常,暂时无法连接'), - duration: Duration(seconds: 2), - ), - ); - return; - } - - debugPrint( - '✅ [全部-EmbeddedRobotList] 提取到时间戳: $timestamp', - ); - - // 调用蓝牙连接流程 - _connectToDeviceViaBluetooth( - context, - robot.name, - timestamp, - ); - return; - } - - // hasHost == true → 走原来的逻辑:前置(setTargetDevice)不变, + // 原 hasHost == true 分支(现在所有设备都走这里):前置(setTargetDevice)不变, // 跳转目标换成新设备详情页(V2 概览设计稿); // 页内铃铛/远程遥控再回旧 RobotControlPage,方便新旧对照。 debugPrint( @@ -2531,7 +2686,9 @@ class _DeviceStatusViewV2State extends State { _buildMenuOption( ctx, icon: Icons.qr_code_scanner, - label: '扫一扫', + label: AppLocalizations.of( + ctx, + ).translate('device_list_v2.scan_action'), onTap: () async { Navigator.pop(ctx); final result = await Navigator.of(context).push( @@ -2553,7 +2710,9 @@ class _DeviceStatusViewV2State extends State { _buildMenuOption( ctx, icon: Icons.add_circle_outline, - label: '添加设备', + label: AppLocalizations.of( + ctx, + ).translate('device_list_v2.add_device'), onTap: () { Navigator.pop(ctx); // TODO: 添加设备功能 @@ -2563,7 +2722,9 @@ class _DeviceStatusViewV2State extends State { _buildMenuOption( ctx, icon: Icons.videocam_outlined, - label: '本地视频', + label: AppLocalizations.of( + ctx, + ).translate('device_list_v2.local_video'), onTap: () { Navigator.pop(ctx); Navigator.of(context).push( @@ -2577,7 +2738,9 @@ class _DeviceStatusViewV2State extends State { _buildMenuOption( ctx, icon: Icons.usb, - label: 'USB 摄像头', + label: AppLocalizations.of( + ctx, + ).translate('device_list_v2.usb_camera'), onTap: () { Navigator.pop(ctx); Navigator.of(context).push( @@ -2766,16 +2929,29 @@ class _DeviceStatusViewV2State extends State { ); } else { debugPrint('⚠️ 未找到匹配的机场,请从无人机机场标签页进入'); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('请从"无人机机场"标签页查看详情'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.view_details_from_tab'), + ), + ), + ); } } else { // 其他设备类型,显示提示 debugPrint('⚠️ [DeviceStatusPage] 未知设备类型: $deviceType'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('该设备类型暂不支持查看详情: $deviceType'), + content: Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.device_type_not_supported').replaceAll( + '%s', + deviceType, + ), + ), duration: const Duration(seconds: 2), ), ); @@ -2821,9 +2997,13 @@ class _DeviceStatusViewV2State extends State { final isBluetoothOn = await bleManager.checkBluetooth(); if (!isBluetoothOn) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('请先打开手机蓝牙'), - duration: Duration(seconds: 2), + SnackBar( + content: Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.open_bluetooth_first'), + ), + duration: const Duration(seconds: 2), ), ); return; @@ -2833,13 +3013,18 @@ class _DeviceStatusViewV2State extends State { showDialog( context: context, barrierDismissible: false, - builder: (_) => const Center( + builder: (_) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('正在搜索蓝牙设备...', style: TextStyle(color: Colors.white)), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + AppLocalizations.of( + context, + ).translate('device_list_v2.searching_bluetooth'), + style: const TextStyle(color: Colors.white), + ), ], ), ), diff --git a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart index 99c9c1e8..7caddbf8 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart @@ -19,6 +19,7 @@ import '../../domain/entities/drone_station_entity.dart'; import '../bloc/drone_station_bloc.dart'; import '../bloc/drone_station_event.dart'; import '../bloc/drone_station_state.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/app_base_map_layer.dart'; /// 无人机视频回传/远程控制页面 class DroneVideoControlPage extends StatefulWidget { @@ -1293,14 +1294,8 @@ class _DroneVideoControlPageState extends State { ), ), children: [ - // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) - // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 - TileLayer( - urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - userAgentPackageName: 'com.example.app', - minZoom: 0, - maxZoom: 19, - ), + // 底图:中文=ESRI,非中文=Google(不可达自动回退 ESRI) + const AppBaseMapLayer(), // 🔥 轨迹线(中间层) if (_trajectoryPoints.length >= 1) @@ -1567,14 +1562,8 @@ class _DroneVideoControlPageState extends State { ), ), children: [ - // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) - // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 - TileLayer( - urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - userAgentPackageName: 'com.example.app', - minZoom: 0, - maxZoom: 19, - ), + // 底图:中文=ESRI,非中文=Google(不可达自动回退 ESRI) + const AppBaseMapLayer(), // 🔥 轨迹线(中间层) if (_trajectoryPoints.length >= 1) diff --git a/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart b/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart index 414f55fc..26d3815b 100644 --- a/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart +++ b/lib/features/v2/device_list/presentation/pages/no_device_page_v2.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart'; import 'package:maibu_satabot_v2/features/v2/local_camera/presentation/pages/local_camera_page.dart'; import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; @@ -40,12 +41,16 @@ class NoDevicePageV2 extends StatelessWidget { stops: [0.0, 0.5, 1.0], ); - static void _todoTap() { - CapsuleToast.show('功能暂未开发', showCheck: false); + void _todoTap(BuildContext context) { + CapsuleToast.show( + AppLocalizations.of(context).translate('device_list_v2.feature_todo'), + showCheck: false, + ); } @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); return Scaffold( backgroundColor: _bgTop, body: Stack( @@ -72,10 +77,10 @@ class NoDevicePageV2 extends StatelessWidget { const SizedBox(height: 18), _buildHeroCard(context), const SizedBox(height: 26), - const Center( + Center( child: Text( - '还没有添加机器人', - style: TextStyle( + l10n.translate('device_list_v2.no_device_title'), + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: _navy, @@ -83,11 +88,11 @@ class NoDevicePageV2 extends StatelessWidget { ), ), const SizedBox(height: 10), - const Center( + Center( child: Text( - '您可以先了解 APP 的功能,\n收到机器人后随时绑定使用。', + l10n.translate('device_list_v2.no_device_desc'), textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 13, color: _grayText, height: 1.6, @@ -97,9 +102,9 @@ class NoDevicePageV2 extends StatelessWidget { const SizedBox(height: 20), _buildAddButton(context), const SizedBox(height: 30), - const Text( - '新手入门', - style: TextStyle( + Text( + l10n.translate('device_list_v2.guide_title'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: _navy, @@ -107,21 +112,24 @@ class NoDevicePageV2 extends StatelessWidget { ), const SizedBox(height: 12), _buildGuideCard( + context: context, icon: Icons.list_alt, - title: '产品操作指南', - subtitle: '了解机器人基础功能和使用流程', + title: l10n.translate('device_list_v2.guide1_title'), + subtitle: l10n.translate('device_list_v2.guide1_desc'), ), const SizedBox(height: 10), _buildGuideCard( + context: context, icon: Icons.videocam_outlined, - title: '观看产品视频', - subtitle: '快速上手遥控、图传及作业功能', + title: l10n.translate('device_list_v2.guide2_title'), + subtitle: l10n.translate('device_list_v2.guide2_desc'), ), const SizedBox(height: 10), _buildGuideCard( + context: context, icon: Icons.help_outline, - title: '帮助与售后服务', - subtitle: '常见问题、报修及客服支持', + title: l10n.translate('device_list_v2.guide3_title'), + subtitle: l10n.translate('device_list_v2.guide3_desc'), ), const SizedBox(height: 24), ], @@ -145,7 +153,10 @@ class NoDevicePageV2 extends StatelessWidget { ), Row( children: [ - _buildTopIconButton(Icons.notifications_none, onTap: _todoTap), + _buildTopIconButton( + Icons.notifications_none, + onTap: () => _todoTap(context), + ), const SizedBox(width: 10), // 设计稿:右上角 ⊕ 是蓝底白号主色按钮,与铃铛的白底按钮区分 _buildTopIconButton( @@ -201,7 +212,10 @@ class NoDevicePageV2 extends StatelessWidget { return BlocBuilder( bloc: GetIt.I(), builder: (context, siteState) { - final name = siteState.selectedSite?.siteName ?? '未选择场站'; + final l10n = AppLocalizations.of(context); + final name = + siteState.selectedSite?.siteName ?? + l10n.translate('device_list_v2.no_site_selected'); return Align( alignment: Alignment.centerRight, child: GestureDetector( @@ -213,7 +227,13 @@ class NoDevicePageV2 extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - isPersonal ? '($name)· 个人账号' : '($name)', + isPersonal + ? l10n + .translate('device_list_v2.site_suffix_personal') + .replaceAll('%s', name) + : l10n + .translate('device_list_v2.site_suffix') + .replaceAll('%s', name), style: const TextStyle(fontSize: 12, color: _grayText), ), if (!isPersonal) @@ -232,12 +252,19 @@ class NoDevicePageV2 extends StatelessWidget { } void _onSiteTap(BuildContext context, SiteState siteState, bool isPersonal) { + final l10n = AppLocalizations.of(context); if (isPersonal) { - CapsuleToast.show('个人账号仅绑定一个场站', showCheck: false); + CapsuleToast.show( + l10n.translate('device_list_v2.personal_single_site'), + showCheck: false, + ); return; } if (siteState.sites.isEmpty) { - CapsuleToast.show('暂无场站数据', showCheck: false); + CapsuleToast.show( + l10n.translate('device_list_v2.no_site_data'), + showCheck: false, + ); return; } final siteCubit = GetIt.I(); @@ -252,12 +279,12 @@ class NoDevicePageV2 extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 14), + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), child: Center( child: Text( - '选择场站', - style: TextStyle( + l10n.translate('device_list_v2.select_site'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: _navy, @@ -374,14 +401,16 @@ class NoDevicePageV2 extends StatelessWidget { ), ], ), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add, color: Colors.white, size: 20), - SizedBox(width: 8), + const Icon(Icons.add, color: Colors.white, size: 20), + const SizedBox(width: 8), Text( - '添加机器人', - style: TextStyle( + AppLocalizations.of( + context, + ).translate('device_list_v2.add_robot'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white, @@ -410,11 +439,13 @@ class NoDevicePageV2 extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 14, 8, 4), child: Row( children: [ - const Expanded( + Expanded( child: Center( child: Text( - '添加机器人', - style: TextStyle( + AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.add_robot'), + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: _navy, @@ -435,7 +466,9 @@ class NoDevicePageV2 extends StatelessWidget { ), _sheetItem( icon: Icons.qr_code_scanner, - label: '扫一扫', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.scan_action'), onTap: () async { Navigator.pop(sheetCtx); final result = await Navigator.of(context).push( @@ -459,17 +492,21 @@ class NoDevicePageV2 extends StatelessWidget { _sheetDivider(), _sheetItem( icon: Icons.add_circle_outline, - label: '添加设备', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.add_device'), onTap: () { Navigator.pop(sheetCtx); // 原菜单该项即为 TODO,保持不跳转 - _todoTap(); + _todoTap(context); }, ), _sheetDivider(), _sheetItem( icon: Icons.videocam_outlined, - label: '本地视频', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.local_video'), onTap: () { Navigator.pop(sheetCtx); Navigator.of(context).push( @@ -480,7 +517,9 @@ class NoDevicePageV2 extends StatelessWidget { _sheetDivider(), _sheetItem( icon: Icons.usb, - label: 'USB 摄像头', + label: AppLocalizations.of( + sheetCtx, + ).translate('device_list_v2.usb_camera'), onTap: () { Navigator.pop(sheetCtx); Navigator.of(context).push( @@ -525,12 +564,13 @@ class NoDevicePageV2 extends StatelessWidget { // ===== 新手入门卡片:二级未开发,统一提示 ===== Widget _buildGuideCard({ + required BuildContext context, required IconData icon, required String title, required String subtitle, }) { return GestureDetector( - onTap: _todoTap, + onTap: () => _todoTap(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration( diff --git a/lib/features/v2/device_list/presentation/widgets/find_robot_sheet.dart b/lib/features/v2/device_list/presentation/widgets/find_robot_sheet.dart index 346b63e1..94a82005 100644 --- a/lib/features/v2/device_list/presentation/widgets/find_robot_sheet.dart +++ b/lib/features/v2/device_list/presentation/widgets/find_robot_sheet.dart @@ -1,14 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../data/models/robot_data_model.dart'; +import '../cubit/find_robot_cubit.dart'; +import '../cubit/find_robot_state.dart'; import 'robot_summary_card_v2.dart'; /// 一键寻车底部弹窗(设计稿:设备控制 → 一键寻车) /// /// 多处复用:全部功能中心、设备详情页、控制页都可以直接调用。 -/// 寻车指令下发通道尚未接入,未注入回调时统一给出明确提示,不假装成功。 +/// 启动寻车已接入真实接口(GET /iot/host/searchDevice,deviceId=设备编码), +/// 走 FindRobotCubit → UseCase → Repository → DataSource;停止/地图入口仍为占位。 Future showFindRobotSheet( BuildContext context, { required RobotDataModel robot, @@ -22,12 +28,15 @@ Future showFindRobotSheet( backgroundColor: Colors.transparent, isScrollControlled: true, barrierColor: const Color(0x8C0B1B2B), - builder: (_) => _FindRobotSheet( - robot: robot, - gnssValid: gnssValid ?? robot.isOnline, - onOpenMap: onOpenMap, - onStartFind: onStartFind, - onStopFind: onStopFind, + builder: (_) => BlocProvider( + create: (_) => GetIt.I(), + child: _FindRobotSheet( + robot: robot, + gnssValid: gnssValid ?? robot.isOnline, + onOpenMap: onOpenMap, + onStartFind: onStartFind, + onStopFind: onStopFind, + ), ), ); } @@ -61,39 +70,81 @@ class _FindRobotSheet extends StatelessWidget { bool get _online => robot.isOnline; void _handleStart(BuildContext context) { + final l10n = AppLocalizations.of(context); if (!_online) { - CapsuleToast.show('设备离线,无法触发声光寻车', showCheck: false); + CapsuleToast.show( + l10n.translate('device_list_v2.fr_toast_offline'), + showCheck: false, + ); return; } + // 外部显式注入回调时优先使用(保留自定义下发通道的能力) if (onStartFind != null) { onStartFind!(); return; } - CapsuleToast.show('声光寻车指令下发未接入', showCheck: false); + // 一键寻车:deviceId 传设备编码(deviceName,MC700- 开头), + // 不是 getSiteList 的数字主键 robot.id;deviceName 异常为空时回退 id 兜底。 + final deviceCode = robot.name.isNotEmpty ? robot.name : robot.id; + context.read().startFind(deviceCode: deviceCode); } - void _handleStop() { - if (onStopFind != null) { - onStopFind!(); + /// 寻车下发结果:成功/失败均弹提示并复位,避免重复触发 + void _handleFindState(BuildContext context, FindRobotState state) { + if (state.isSubmitting) return; + + final l10n = AppLocalizations.of(context); + final cubit = context.read(); + + if (state.isSuccess) { + CapsuleToast.show(l10n.translate('device_list_v2.fr_toast_start_sent')); + cubit.reset(); return; } - CapsuleToast.show('停止寻车指令下发未接入', showCheck: false); + + final message = state.errorMessage; + if (message == null) return; + CapsuleToast.show( + message.isEmpty + ? l10n.translate('device_list_v2.fr_toast_start_failed') + : message, + showCheck: false, + ); + cubit.reset(); } + // 一键寻车为一次性下发,无「停止」语义,停止按钮已按需求注释掉(方法一并保留注释备恢复)。 + // void _handleStop(BuildContext context) { + // if (onStopFind != null) { + // onStopFind!(); + // return; + // } + // CapsuleToast.show( + // AppLocalizations.of(context).translate('device_list_v2.fr_toast_stop_todo'), + // showCheck: false, + // ); + // } + void _handleOpenMap(BuildContext context) { if (onOpenMap != null) { onOpenMap!(); return; } - CapsuleToast.show('地图定位入口暂未接入', showCheck: false); + CapsuleToast.show( + AppLocalizations.of(context).translate('device_list_v2.fr_toast_map_todo'), + showCheck: false, + ); } @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final model = RobotSummaryCardV2.modelOf(robot); - return SafeArea( - top: false, - child: Container( + return BlocListener( + listener: _handleFindState, + child: SafeArea( + top: false, + child: Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), @@ -107,10 +158,10 @@ class _FindRobotSheet extends StatelessWidget { // 标题行 Row( children: [ - const Expanded( + Expanded( child: Text( - '一键寻车', - style: TextStyle( + l10n.translate('device_list_v2.fr_title'), + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w800, color: _navy, @@ -163,7 +214,7 @@ class _FindRobotSheet extends StatelessWidget { ), const SizedBox(height: 5), Text( - '${gnssValid ? 'GNSS定位有效' : 'GNSS定位无效'} · $model 标准版', + '${gnssValid ? l10n.translate('device_list_v2.fr_gnss_valid') : l10n.translate('device_list_v2.fr_gnss_invalid')} · ${l10n.translate('device_list_v2.fr_model_std').replaceAll('%s', model)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: _gray), @@ -181,7 +232,9 @@ class _FindRobotSheet extends StatelessWidget { borderRadius: BorderRadius.circular(9), ), child: Text( - _online ? '在线' : '离线', + _online + ? l10n.translate('device_list_v2.online') + : l10n.translate('device_list_v2.offline'), style: TextStyle( fontSize: 10.5, fontWeight: FontWeight.w600, @@ -219,22 +272,25 @@ class _FindRobotSheet extends StatelessWidget { ), ), const SizedBox(width: 12), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '在地图中查看机器人', - style: TextStyle( + l10n.translate('device_list_v2.fr_map_entry'), + style: const TextStyle( fontSize: 14.5, fontWeight: FontWeight.w800, color: _navy, ), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( - '定位位置与最后更新时间', - style: TextStyle(fontSize: 12, color: _gray), + l10n.translate('device_list_v2.fr_map_entry_sub'), + style: const TextStyle( + fontSize: 12, + color: _gray, + ), ), ], ), @@ -260,25 +316,32 @@ class _FindRobotSheet extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '声光寻车', - style: TextStyle( + Text( + l10n.translate('device_list_v2.fr_sound_title'), + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w800, color: _navy, ), ), const SizedBox(height: 8), - const Text( - '触发蜂鸣与指示灯,帮助现场人员找到设备;默认自动停止。', - style: TextStyle(fontSize: 12, height: 1.5, color: _gray), + Text( + l10n.translate('device_list_v2.fr_sound_desc'), + style: const TextStyle( + fontSize: 12, + height: 1.5, + color: _gray, + ), ), const SizedBox(height: 12), Row( children: [ - const Text( - '自动结束', - style: TextStyle(fontSize: 12.5, color: _gray), + Text( + l10n.translate('device_list_v2.fr_auto_stop'), + style: const TextStyle( + fontSize: 12.5, + color: _gray, + ), ), const Spacer(), Container( @@ -290,9 +353,9 @@ class _FindRobotSheet extends StatelessWidget { color: _softBlue, borderRadius: BorderRadius.circular(10), ), - child: const Text( - '10秒(示例)', - style: TextStyle( + child: Text( + l10n.translate('device_list_v2.fr_auto_stop_value'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: _blue, @@ -324,18 +387,18 @@ class _FindRobotSheet extends StatelessWidget { ] : null, ), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.volume_up_rounded, size: 19, color: Colors.white, ), - SizedBox(width: 8), + const SizedBox(width: 8), Text( - '启动寻车提示', - style: TextStyle( + l10n.translate('device_list_v2.fr_start'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white, @@ -345,38 +408,43 @@ class _FindRobotSheet extends StatelessWidget { ), ), ), - const SizedBox(height: 10), - // 停止寻车 - GestureDetector( - onTap: _handleStop, - behavior: HitTestBehavior.opaque, - child: Container( - height: 48, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _blue, width: 1.2), - ), - child: const Text( - '停止寻车', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: _blue, - ), - ), - ), - ), + // 停止寻车:一次性下发无停止语义,按需求注释掉该按钮。 + // const SizedBox(height: 10), + // GestureDetector( + // onTap: () => _handleStop(context), + // behavior: HitTestBehavior.opaque, + // child: Container( + // height: 48, + // alignment: Alignment.center, + // decoration: BoxDecoration( + // color: Colors.white, + // borderRadius: BorderRadius.circular(12), + // border: Border.all(color: _blue, width: 1.2), + // ), + // child: Text( + // l10n.translate('device_list_v2.fr_stop'), + // style: const TextStyle( + // fontSize: 15, + // fontWeight: FontWeight.w700, + // color: _blue, + // ), + // ), + // ), + // ), const SizedBox(height: 12), - const Text( - '设备离线时不可触发声光寻车,只能查看最后已知位置。', - style: TextStyle(fontSize: 11.5, height: 1.5, color: _hint), + Text( + l10n.translate('device_list_v2.fr_note'), + style: const TextStyle( + fontSize: 11.5, + height: 1.5, + color: _hint, + ), ), ], ), ), ), + ), ); } } diff --git a/lib/features/v2/my/presentation/pages/my_page_v2.dart b/lib/features/v2/my/presentation/pages/my_page_v2.dart index bbca6a8a..e86efaf5 100644 --- a/lib/features/v2/my/presentation/pages/my_page_v2.dart +++ b/lib/features/v2/my/presentation/pages/my_page_v2.dart @@ -7,6 +7,7 @@ import 'package:maibu_satabot_v2/core/router/route_paths.dart'; import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; import 'package:maibu_satabot_v2/core/utils/image_url_util.dart'; import 'package:maibu_satabot_v2/components/capsule_toast.dart'; +import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/help_feedback_page.dart'; import 'package:maibu_satabot_v2/features/v2/my/presentation/pages/profile_detail_page.dart'; @@ -41,14 +42,20 @@ class MyPageV2 extends StatelessWidget { static const _cardBorder = Color(0xFFE9F1FA); /// 未实现入口的统一提示 - static void _todoTap() { - CapsuleToast.show('功能暂未开发', showCheck: false); + static void _todoTap(BuildContext context) { + CapsuleToast.show( + AppLocalizations.of(context).translate('my_v2.todo_developing'), + showCheck: false, + ); } @override Widget build(BuildContext context) { final user = context.watch().state.user; - final userName = user?.nickname ?? user?.username ?? 'SATA用户'; + final userName = + user?.nickname ?? + user?.username ?? + AppLocalizations.of(context).translate('my_v2.default_user_name'); final userContact = user?.email ?? user?.phone; return Scaffold( @@ -100,21 +107,25 @@ class MyPageV2 extends StatelessWidget { const SizedBox(height: 14), _buildStatsRow(context), const SizedBox(height: 22), - _buildSectionTitle('服务支持'), + _buildSectionTitle( + AppLocalizations.of(context).translate('my_v2.service_support'), + ), const SizedBox(height: 10), _buildServiceGroup(context), const SizedBox(height: 22), - _buildSectionTitle('账户与设置'), + _buildSectionTitle( + AppLocalizations.of(context).translate('my_v2.account_settings'), + ), const SizedBox(height: 10), _buildAccountGroup(context), const SizedBox(height: 16), - _buildNoteCard(), + _buildNoteCard(context), const SizedBox(height: 14), // 页脚灰字(设计稿) - const Center( + Center( child: Text( - '设计原型 · 数据仅供演示 · 不连接真实设备或账号', - style: TextStyle(fontSize: 11, color: _grayText), + AppLocalizations.of(context).translate('my_v2.footer_note'), + style: const TextStyle(fontSize: 11, color: _grayText), ), ), const SizedBox(height: 10), @@ -142,7 +153,10 @@ class MyPageV2 extends StatelessWidget { ), Row( children: [ - _buildTopIconButton(Icons.notifications_none, onTap: _todoTap), + _buildTopIconButton( + Icons.notifications_none, + onTap: () => _todoTap(context), + ), const SizedBox(width: 10), _buildTopIconButton( Icons.settings_outlined, @@ -184,6 +198,7 @@ class MyPageV2 extends StatelessWidget { String? userContact, String? avatarUrl, ) { + final l10n = AppLocalizations.of(context); return GestureDetector( onTap: () => _push(context, const ProfileDetailPage()), // 切图自身已烘进圆角、细描边与透明留白:不能再套 ClipRRect+cover, @@ -228,24 +243,24 @@ class MyPageV2 extends StatelessWidget { ), const SizedBox(height: 3), Text( - '个人账号 · ${userContact ?? '未设置联系方式'}', + '${l10n.translate('my_v2.personal_account')} · ${userContact ?? l10n.translate('my_v2.contact_not_set')}', style: const TextStyle( fontSize: 12.5, color: _subText, ), ), const SizedBox(height: 8), - const Row( + Row( children: [ Text( - '编辑个人资料', - style: TextStyle( + l10n.translate('my_v2.edit_profile'), + style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w600, color: _primaryBlue, ), ), - Icon( + const Icon( Icons.chevron_right, size: 16, color: _primaryBlue, @@ -288,15 +303,16 @@ class MyPageV2 extends StatelessWidget { // ===== 三个数据卡 ===== Widget _buildStatsRow(BuildContext context) { + final l10n = AppLocalizations.of(context); return Row( children: [ Expanded( child: _buildStatCard( icon: Icons.assignment_outlined, iconColor: _primaryBlue, - value: '未知', - label: '处理中工单', - onTap: _todoTap, + value: l10n.translate('my_v2.unknown'), + label: l10n.translate('my_v2.stat_processing_order'), + onTap: () => _todoTap(context), ), ), const SizedBox(width: 12), @@ -304,9 +320,9 @@ class MyPageV2 extends StatelessWidget { child: _buildStatCard( icon: Icons.build_outlined, iconColor: _orange, - value: '未知', - label: '保养提醒', - onTap: _todoTap, + value: l10n.translate('my_v2.unknown'), + label: l10n.translate('my_v2.stat_maintenance_reminder'), + onTap: () => _todoTap(context), ), ), const SizedBox(width: 12), @@ -314,8 +330,8 @@ class MyPageV2 extends StatelessWidget { child: _buildStatCard( icon: Icons.article_outlined, iconColor: _primaryBlue, - value: '教程', - label: '使用与帮助', + value: l10n.translate('my_v2.stat_tutorial'), + label: l10n.translate('my_v2.stat_usage_help'), onTap: () => _push(context, const HelpFeedbackPage()), ), ), @@ -399,64 +415,66 @@ class MyPageV2 extends StatelessWidget { } Widget _buildServiceGroup(BuildContext context) { + final l10n = AppLocalizations.of(context); return _buildGroupCard([ _buildMenuRow( icon: Icons.headset_mic_outlined, iconBg: _iconBgBlue, iconColor: _primaryBlue, - title: '售后服务中心', - subtitle: '报修、工单、保修查询', - onTap: _todoTap, + title: l10n.translate('my_v2.after_sales_center'), + subtitle: l10n.translate('my_v2.after_sales_center_desc'), + onTap: () => _todoTap(context), ), _buildRowDivider(), _buildMenuRow( icon: Icons.build_outlined, iconBg: _iconBgOrange, iconColor: _orange, - title: '维修保养', - subtitle: '保养计划、提醒与维护记录', - onTap: _todoTap, + title: l10n.translate('my_v2.maintenance'), + subtitle: l10n.translate('my_v2.maintenance_desc'), + onTap: () => _todoTap(context), ), _buildRowDivider(), _buildMenuRow( icon: Icons.menu_book_outlined, iconBg: _iconBgBlue, iconColor: _primaryBlue, - title: '帮助与操作指南', - subtitle: '产品说明书、教程及常见问题', + title: l10n.translate('my_v2.help_guide'), + subtitle: l10n.translate('my_v2.help_guide_desc'), onTap: () => _push(context, const HelpFeedbackPage()), ), ]); } Widget _buildAccountGroup(BuildContext context) { + final l10n = AppLocalizations.of(context); return _buildGroupCard([ _buildMenuRow( icon: Icons.verified_user_outlined, iconBg: _iconBgBlue, iconColor: _primaryBlue, - title: '账号与安全', - subtitle: '邮箱/手机、密码及登录设备', - onTap: _todoTap, + title: l10n.translate('my_v2.account_security'), + subtitle: l10n.translate('my_v2.account_security_desc'), + onTap: () => _todoTap(context), ), _buildRowDivider(), _buildMenuRow( icon: Icons.notifications_none, iconBg: _iconBgBlue, iconColor: _primaryBlue, - title: '消息通知', - subtitle: '设备、作业、服务及授权消息', + title: l10n.translate('my_v2.message_notice'), + subtitle: l10n.translate('my_v2.message_notice_desc'), // 未读接口暂无,按约定显示「未知」 - trailingText: '未读未知', - onTap: _todoTap, + trailingText: l10n.translate('my_v2.unread_unknown'), + onTap: () => _todoTap(context), ), _buildRowDivider(), _buildMenuRow( icon: Icons.settings_outlined, iconBg: _iconBgBlue, iconColor: _primaryBlue, - title: '通用设置', - subtitle: '语言、单位、地区及APP偏好', + title: l10n.translate('my_v2.general_settings'), + subtitle: l10n.translate('my_v2.general_settings_desc'), onTap: () => _push(context, const SystemSettingsPage()), ), _buildRowDivider(), @@ -464,9 +482,9 @@ class MyPageV2 extends StatelessWidget { icon: Icons.info_outline, iconBg: _iconBgBlue, iconColor: _primaryBlue, - title: '关于与法律协议', - subtitle: 'APP版本、协议和隐私政策', - onTap: _todoTap, + title: l10n.translate('my_v2.about_legal'), + subtitle: l10n.translate('my_v2.about_legal_desc'), + onTap: () => _todoTap(context), ), ]); } @@ -535,7 +553,7 @@ class MyPageV2 extends StatelessWidget { } // ===== 底部说明卡(设计稿原文) ===== - Widget _buildNoteCard() { + Widget _buildNoteCard(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(14), @@ -543,9 +561,9 @@ class MyPageV2 extends StatelessWidget { color: _noteBg, borderRadius: BorderRadius.circular(12), ), - child: const Text( - '设备共享、成员授权、管理员移交与解绑转让沿用「设备」模块已有流程,本模块不重复开发。', - style: TextStyle(fontSize: 12.5, color: _subText, height: 1.6), + child: Text( + AppLocalizations.of(context).translate('my_v2.note_card'), + style: const TextStyle(fontSize: 12.5, color: _subText, height: 1.6), ), ); } @@ -555,13 +573,13 @@ class MyPageV2 extends StatelessWidget { return GestureDetector( onTap: () => _showLogoutDialog(context), behavior: HitTestBehavior.opaque, - child: const SizedBox( + child: SizedBox( width: double.infinity, height: 44, child: Center( child: Text( - '退出登录', - style: TextStyle( + AppLocalizations.of(context).translate('my_v2.logout'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFFF53F3F), @@ -573,15 +591,16 @@ class MyPageV2 extends StatelessWidget { } void _showLogoutDialog(BuildContext context) { + final l10n = AppLocalizations.of(context); showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('退出登录'), - content: const Text('确定要退出登录吗?'), + title: Text(l10n.translate('my_v2.logout')), + content: Text(l10n.translate('my_v2.logout_confirm')), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), - child: const Text('取消'), + child: Text(l10n.translate('my_v2.cancel')), ), TextButton( onPressed: () async { @@ -595,7 +614,7 @@ class MyPageV2 extends StatelessWidget { style: TextButton.styleFrom( foregroundColor: const Color(0xFFF53F3F), ), - child: const Text('确定'), + child: Text(l10n.translate('my_v2.confirm')), ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 9579f59f..3599e62e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -124,16 +124,21 @@ class MyApp extends StatelessWidget { BlocProvider.value(value: localeCubit), BlocProvider.value(value: themeCubit), BlocProvider.value(value: sl()), - BlocProvider( - create: (_) => sl(), + // 🔥 以下三个都是 GetIt 的 lazySingleton(injection.dart),实例不归 widget 树 + // 所有:用 create: 时 MultiProvider 卸载会对它们调 close(),而 LazySingleton + // 不会重建 → 整模块静默失效(与 home_routes.dart 里遥控路由的同类问题)。 + // 改 .value,与上方 DeviceStatusBloc / LocaleCubit / ThemeCubit 写法一致。 + // (UpdateCubit 是真正的“这里新建一个”,保留 create。) + BlocProvider.value( + value: sl(), ), BlocProvider( create: (_) => UpdateCubit(VersionCheckService()), ), - BlocProvider( - create: (_) => sl(), + BlocProvider.value( + value: sl(), ), - BlocProvider(create: (_) => sl()), + BlocProvider.value(value: sl()), ], child: BlocBuilder( bloc: localeCubit,