Browse Source

multi language support and guide

enzo 5 ngày trước cách đây
mục cha
commit
28dd10d7dc

+ 166 - 0
assets/lang/en.json

@@ -0,0 +1,166 @@
+{
+  "app_title": "Palm Oil Ripeness AI",
+  "total_scans": "Total Scans",
+  "analyze_gallery_title": "Analyze Gallery",
+  "analyze_gallery_subtitle": "Detect ripeness from photos",
+  "snap_analyze_title": "Snap & Analyze",
+  "snap_analyze_subtitle": "Manual high-res capture",
+  "live_inference_title": "Live Inference",
+  "live_inference_subtitle": "Real-time \"Point-and-Scan\"",
+  "history_vault_title": "History Vault",
+  "history_vault_subtitle": "View previous detections",
+  "theme_label": "Theme",
+  "language_label": "Language",
+  "settings_label": "Settings",
+  "theme_light": "Light",
+  "theme_dark": "Dark",
+  "theme_system": "System",
+
+  "confidence_label": "Confidence: {percent}%",
+  "health_alert_abnormal": "HEALTH ALERT: Abnormal detected!",
+  "ai_analyzing": "AI is Analyzing...",
+  "retry_button": "Retry",
+  "dismiss_button": "Dismiss",
+  "error_details_label": "Error Details",
+  "done_button": "Done",
+
+  "analysis_appbar_title": "AI Analysis",
+  "analysis_init_error": "Failed to initialize AI model: {error}",
+  "analysis_select_photo_hint": "Select a photo to start AI analysis",
+  "analysis_pick_button": "Pick Images from Gallery",
+  "analysis_bunches_detected": "{count} bunches detected",
+  "analysis_progress_of": "Analyzing image {current} of {total}",
+  "analysis_progress_stats": "{processed} processed · {skipped} skipped",
+  "analysis_optimizing_hint": "Optimizing detection parameters",
+  "analysis_no_detections_batch": "No palm bunches detected in any selected image.",
+
+  "camera_permission_denied": "Camera permission denied.",
+  "no_cameras_found": "No cameras found.",
+  "camera_init_error": "Camera init error: {error}",
+  "static_appbar_title": "Capture & Analyze",
+  "static_no_detections": "No palm bunches detected.",
+  "static_capture_error": "Error during capture/analysis: {error}",
+  "result_sheet_title": "Analysis Result",
+  "label_ripeness": "Ripeness",
+  "label_confidence": "Confidence",
+  "health_alert_title": "HEALTH ALERT",
+  "health_alert_message": "Abnormal features detected. Check palm health.",
+  "yield_warning_title": "YIELD WARNING",
+  "yield_warning_message": "Potential Yield Loss due to premature harvest.",
+  "acknowledge_button": "Acknowledge",
+
+  "live_no_detections_snackbar": "No palm bunches detected in final snap.",
+  "live_state_cooldown": "COOLDOWN",
+  "live_state_locking": "LOCKING",
+  "live_state_searching": "SEARCHING...",
+  "live_resuming_scan": "Resuming scan...",
+  "live_hold_steady_hint": "Hold steady to lock target",
+
+  "cancel_button": "Cancel",
+  "delete_button": "Delete",
+  "reset_button": "Reset",
+  "clear_filters_button": "Clear Filters",
+
+  "history_reset_title": "Reset History",
+  "history_reset_message": "Are you sure you want to clear all analysis records? This action is irreversible.",
+  "history_cleared_snackbar": "History cleared successfully.",
+  "history_delete_record_confirm": "Delete this record? This action is irreversible.",
+  "history_delete_batch_confirm": "Delete this batch of {count} scans? This action is irreversible.",
+  "filter_sort_title": "Filter & Sort",
+  "sort_order_label": "Sort Order",
+  "sort_latest_first": "Latest First",
+  "sort_oldest_first": "Oldest First",
+  "date_range_label": "Date Range",
+  "all_dates_label": "All Dates",
+  "clear_date_range_tooltip": "Clear date range",
+  "reset_history_tooltip": "Reset History",
+  "history_empty_no_records": "No records found yet.",
+  "history_empty_no_matches": "No records match the selected filters.",
+  "ungrouped_scans_label": "Ungrouped Scans",
+  "batch_scans_label": "Batch • {count} scans",
+  "majority_label": "Majority: {className}\n{timeRange}",
+  "record_conf_timestamp": "Conf: {percent}% | {timestamp}",
+
+  "class_Ripe": "Ripe",
+  "class_Unripe": "Unripe",
+  "class_Underripe": "Underripe",
+  "class_Overripe": "Overripe",
+  "class_Abnormal": "Abnormal",
+  "class_Empty_Bunch": "Empty Bunch",
+
+  "na_label": "N/A",
+  "saving_label": "Saving...",
+  "ai_result_label": "AI Result",
+  "added_suffix": " • Added",
+
+  "batch_report_title": "Batch Report",
+  "batch_no_records": "No records found for this batch.",
+  "kpi_frames": "Frames",
+  "kpi_total_detections": "Total Detections",
+  "kpi_avg_confidence": "Avg Confidence",
+  "kpi_avg_inference": "Avg Inference",
+  "kpi_start": "Start",
+  "kpi_end": "End",
+  "mpob_class_distribution_title": "MPOB Class Distribution",
+  "batch_corrected_label": "Corrected",
+  "no_detections_in_batch": "No detections in this batch.",
+  "frames_corrected_pill": "{count} of {total} frames corrected",
+  "frames_section_title": "Frames ({count})",
+
+  "frame_of_title": "Frame {current} of {total}",
+  "record_details_title": "Record Details",
+  "your_correction_label": "Your Correction",
+  "tap_to_correct_hint": "Tap a bunch on the photo to correct its grade.",
+  "last_corrected_label": "Last corrected: {timestamp}",
+  "add_missed_bunch_button": "Add Missed Bunch",
+  "correction_reset_snackbar": "Correction reset.",
+  "label_file_name": "File Name",
+  "label_primary_grade": "Primary Grade",
+  "label_inference_time": "Inference Time",
+  "label_timestamp": "Timestamp",
+  "frame_summary_title": "Frame Summary",
+  "analytical_breakdown_title": "Analytical Breakdown",
+  "corrected_breakdown_title": "Corrected Breakdown (tap a bunch above to edit or remove)",
+  "industrial_summary_text": "Industrial Summary: This image has been verified for harvest quality. All detected bunches are categorized according to ripeness standards.",
+
+  "correct_grade_title": "Correct Grade",
+  "remove_detection_button": "Remove this detection",
+
+  "undo_tooltip": "Undo",
+  "redo_tooltip": "Redo",
+  "hide_reference_tooltip": "Hide AI reference boxes",
+  "show_reference_tooltip": "Show AI reference boxes",
+  "editor_instruction_text": "Tap anywhere a bunch was missed to place a box, then grade it and drag to fit. Existing detections are dimmed for reference.",
+  "pick_grade_placeholder": "Pick grade",
+  "editor_tap_to_add_hint": "Tap the photo to add a bunch",
+  "editor_bunches_added_singular": "{count} bunch added",
+  "editor_bunches_added_plural": "{count} bunches added",
+
+  "manual_title": "User Manual",
+
+  "manual_gallery_title": "Analyze Gallery",
+  "manual_gallery_body": "Pick one or more photos from your gallery to analyze at once. Every photo you select is processed as a single batch — once all photos are done, you'll be taken straight to the Batch Report for that batch, showing every frame's results together.",
+
+  "manual_snap_title": "Snap & Analyze",
+  "manual_snap_body": "Take one photo at a time with the camera's manual shutter. As soon as the photo is analyzed, a result sheet shows the detected grade and confidence immediately — best for checking a single bunch carefully.",
+
+  "manual_live_title": "Live Inference",
+  "manual_live_body": "Point the camera at a bunch and hold steady — the app automatically locks on and captures once it's confident, showing the result right away. This is a quick verification tool only: results are not saved to History Vault. If you want to keep a record, use Snap & Analyze instead.",
+
+  "manual_history_title": "History Vault",
+  "manual_history_body": "Browse every past scan, grouped by batch. Filter by date range, sort latest or oldest first, and swipe left on any entry to delete it. Tap a batch to open its Batch Report, or a single scan to open its Record Details.",
+
+  "manual_correction_title": "Correcting AI Results",
+  "manual_correction_body": "On any photo's detail screen, tap a detected bunch to change its grade or remove it if the AI was wrong — this never edits the AI's original result, it only adds your correction on top, which you can always undo with Reset. If the AI missed a bunch entirely, use \"Add Missed Bunch\" to place, grade, and size a new box for it. Once you've made any correction, the screen defaults to showing \"Your Correction\" instead of the AI's original result.",
+
+  "manual_batch_title": "Batch Report",
+  "manual_batch_body": "Shows a summary for an entire batch of scans: frame count, total detections, average confidence and inference time, and a class-distribution bar. If any photos in the batch have been corrected, a second \"Corrected\" distribution bar appears alongside the AI's own, so you can compare them directly.",
+
+  "manual_classes_title": "MPOB Grading Classes",
+  "manual_class_desc_Ripe": "Ready for harvest — the ideal grade, with the right balance of loose fruit and color for maximum oil yield.",
+  "manual_class_desc_Unripe": "Too early to harvest — dark, unripe fruit with no loose fruit yet. Leave the bunch to mature further.",
+  "manual_class_desc_Underripe": "Close to ready, but not quite there — fewer loose fruit than a fully ripe bunch. Consider waiting a few more days.",
+  "manual_class_desc_Overripe": "Harvested later than ideal — oil quality starts to decline the longer it's left past this point.",
+  "manual_class_desc_Abnormal": "Shows defects such as pest damage, disease, or poor pollination — flagged separately from normal ripeness grading.",
+  "manual_class_desc_Empty_Bunch": "Most of the fruit has already detached from the bunch — little to no yield remains."
+}

+ 166 - 0
assets/lang/ms.json

@@ -0,0 +1,166 @@
+{
+  "app_title": "AI Kematangan Buah Sawit",
+  "total_scans": "Jumlah Imbasan",
+  "analyze_gallery_title": "Analisis Galeri",
+  "analyze_gallery_subtitle": "Kesan kematangan daripada foto",
+  "snap_analyze_title": "Tangkap & Analisis",
+  "snap_analyze_subtitle": "Tangkapan resolusi tinggi secara manual",
+  "live_inference_title": "Inferens Langsung",
+  "live_inference_subtitle": "\"Tunjuk-dan-Imbas\" masa nyata",
+  "history_vault_title": "Sejarah Imbasan",
+  "history_vault_subtitle": "Lihat pengesanan lepas",
+  "theme_label": "Tema",
+  "language_label": "Bahasa",
+  "settings_label": "Tetapan",
+  "theme_light": "Cerah",
+  "theme_dark": "Gelap",
+  "theme_system": "Sistem",
+
+  "confidence_label": "Keyakinan: {percent}%",
+  "health_alert_abnormal": "AMARAN KESIHATAN: Tidak normal dikesan!",
+  "ai_analyzing": "AI sedang Menganalisis...",
+  "retry_button": "Cuba Lagi",
+  "dismiss_button": "Tutup",
+  "error_details_label": "Butiran Ralat",
+  "done_button": "Selesai",
+
+  "analysis_appbar_title": "Analisis AI",
+  "analysis_init_error": "Gagal memulakan model AI: {error}",
+  "analysis_select_photo_hint": "Pilih foto untuk mula analisis AI",
+  "analysis_pick_button": "Pilih Imej daripada Galeri",
+  "analysis_bunches_detected": "{count} tandan dikesan",
+  "analysis_progress_of": "Menganalisis imej {current} daripada {total}",
+  "analysis_progress_stats": "{processed} diproses · {skipped} dilangkau",
+  "analysis_optimizing_hint": "Mengoptimumkan parameter pengesanan",
+  "analysis_no_detections_batch": "Tiada tandan sawit dikesan dalam mana-mana imej yang dipilih.",
+
+  "camera_permission_denied": "Kebenaran kamera ditolak.",
+  "no_cameras_found": "Tiada kamera ditemui.",
+  "camera_init_error": "Ralat permulaan kamera: {error}",
+  "static_appbar_title": "Tangkap & Analisis",
+  "static_no_detections": "Tiada tandan sawit dikesan.",
+  "static_capture_error": "Ralat semasa tangkapan/analisis: {error}",
+  "result_sheet_title": "Keputusan Analisis",
+  "label_ripeness": "Kematangan",
+  "label_confidence": "Keyakinan",
+  "health_alert_title": "AMARAN KESIHATAN",
+  "health_alert_message": "Ciri tidak normal dikesan. Semak kesihatan sawit.",
+  "yield_warning_title": "AMARAN HASIL",
+  "yield_warning_message": "Potensi kehilangan hasil akibat penuaian pramatang.",
+  "acknowledge_button": "Faham",
+
+  "live_no_detections_snackbar": "Tiada tandan sawit dikesan dalam tangkapan akhir.",
+  "live_state_cooldown": "PENYEJUKAN",
+  "live_state_locking": "MENGUNCI",
+  "live_state_searching": "MENCARI...",
+  "live_resuming_scan": "Menyambung imbasan...",
+  "live_hold_steady_hint": "Kekal stabil untuk mengunci sasaran",
+
+  "cancel_button": "Batal",
+  "delete_button": "Padam",
+  "reset_button": "Set Semula",
+  "clear_filters_button": "Kosongkan Penapis",
+
+  "history_reset_title": "Set Semula Sejarah",
+  "history_reset_message": "Adakah anda pasti mahu memadam semua rekod analisis? Tindakan ini tidak boleh diundur.",
+  "history_cleared_snackbar": "Sejarah berjaya dikosongkan.",
+  "history_delete_record_confirm": "Padam rekod ini? Tindakan ini tidak boleh diundur.",
+  "history_delete_batch_confirm": "Padam kumpulan {count} imbasan ini? Tindakan ini tidak boleh diundur.",
+  "filter_sort_title": "Tapis & Susun",
+  "sort_order_label": "Susunan",
+  "sort_latest_first": "Terkini Dahulu",
+  "sort_oldest_first": "Terlama Dahulu",
+  "date_range_label": "Julat Tarikh",
+  "all_dates_label": "Semua Tarikh",
+  "clear_date_range_tooltip": "Kosongkan julat tarikh",
+  "reset_history_tooltip": "Set Semula Sejarah",
+  "history_empty_no_records": "Tiada rekod ditemui lagi.",
+  "history_empty_no_matches": "Tiada rekod sepadan dengan penapis yang dipilih.",
+  "ungrouped_scans_label": "Imbasan Tidak Berkumpulan",
+  "batch_scans_label": "Kumpulan • {count} imbasan",
+  "majority_label": "Majoriti: {className}\n{timeRange}",
+  "record_conf_timestamp": "Keyakinan: {percent}% | {timestamp}",
+
+  "class_Ripe": "Tandan Masak",
+  "class_Unripe": "Tandan Muda",
+  "class_Underripe": "Tandan Mengkal",
+  "class_Overripe": "Tandan Terlebih Masak",
+  "class_Abnormal": "Tandan Tidak Normal",
+  "class_Empty_Bunch": "Tandan Kosong",
+
+  "na_label": "N/A",
+  "saving_label": "Menyimpan...",
+  "ai_result_label": "Keputusan AI",
+  "added_suffix": " • Ditambah",
+
+  "batch_report_title": "Laporan Kumpulan",
+  "batch_no_records": "Tiada rekod ditemui untuk kumpulan ini.",
+  "kpi_frames": "Bingkai",
+  "kpi_total_detections": "Jumlah Pengesanan",
+  "kpi_avg_confidence": "Purata Keyakinan",
+  "kpi_avg_inference": "Purata Inferens",
+  "kpi_start": "Mula",
+  "kpi_end": "Tamat",
+  "mpob_class_distribution_title": "Taburan Kelas MPOB",
+  "batch_corrected_label": "Dibetulkan",
+  "no_detections_in_batch": "Tiada pengesanan dalam kumpulan ini.",
+  "frames_corrected_pill": "{count} daripada {total} bingkai dibetulkan",
+  "frames_section_title": "Bingkai ({count})",
+
+  "frame_of_title": "Bingkai {current} daripada {total}",
+  "record_details_title": "Butiran Rekod",
+  "your_correction_label": "Pembetulan Anda",
+  "tap_to_correct_hint": "Ketik tandan pada foto untuk membetulkan gredannya.",
+  "last_corrected_label": "Terakhir dibetulkan: {timestamp}",
+  "add_missed_bunch_button": "Tambah Tandan Terlepas",
+  "correction_reset_snackbar": "Pembetulan ditetapkan semula.",
+  "label_file_name": "Nama Fail",
+  "label_primary_grade": "Gred Utama",
+  "label_inference_time": "Masa Inferens",
+  "label_timestamp": "Cap Waktu",
+  "frame_summary_title": "Ringkasan Bingkai",
+  "analytical_breakdown_title": "Pecahan Analitik",
+  "corrected_breakdown_title": "Pecahan Dibetulkan (ketik tandan di atas untuk edit atau buang)",
+  "industrial_summary_text": "Ringkasan Industri: Imej ini telah disahkan untuk kualiti penuaian. Semua tandan yang dikesan dikategorikan mengikut piawaian kematangan.",
+
+  "correct_grade_title": "Betulkan Gred",
+  "remove_detection_button": "Buang pengesanan ini",
+
+  "undo_tooltip": "Buat Asal",
+  "redo_tooltip": "Buat Semula",
+  "hide_reference_tooltip": "Sembunyikan kotak rujukan AI",
+  "show_reference_tooltip": "Tunjukkan kotak rujukan AI",
+  "editor_instruction_text": "Ketik mana-mana bahagian yang terlepas untuk meletakkan kotak, kemudian gredkan dan seret untuk menyesuaikan. Pengesanan sedia ada diredupkan sebagai rujukan.",
+  "pick_grade_placeholder": "Pilih gred",
+  "editor_tap_to_add_hint": "Ketik foto untuk menambah tandan",
+  "editor_bunches_added_singular": "{count} tandan ditambah",
+  "editor_bunches_added_plural": "{count} tandan ditambah",
+
+  "manual_title": "Manual Pengguna",
+
+  "manual_gallery_title": "Analisis Galeri",
+  "manual_gallery_body": "Pilih satu atau lebih foto daripada galeri anda untuk dianalisis sekali gus. Setiap foto yang dipilih diproses sebagai satu kumpulan — apabila semua foto selesai, anda akan dibawa terus ke Laporan Kumpulan untuk kumpulan tersebut, menunjukkan keputusan semua bingkai bersama-sama.",
+
+  "manual_snap_title": "Tangkap & Analisis",
+  "manual_snap_body": "Ambil satu foto pada satu masa menggunakan shutter manual kamera. Sebaik sahaja foto dianalisis, helaian keputusan akan menunjukkan gred dan keyakinan yang dikesan dengan segera — sesuai untuk memeriksa satu tandan dengan teliti.",
+
+  "manual_live_title": "Inferens Langsung",
+  "manual_live_body": "Halakan kamera ke arah tandan dan kekal stabil — aplikasi akan mengunci secara automatik dan menangkap sebaik sahaja ia yakin, menunjukkan keputusan serta-merta. Ini hanyalah alat pengesahan pantas: keputusan tidak disimpan ke Sejarah Imbasan. Jika anda mahu menyimpan rekod, gunakan Tangkap & Analisis.",
+
+  "manual_history_title": "Sejarah Imbasan",
+  "manual_history_body": "Semak imbas semua imbasan lepas, dikumpulkan mengikut kumpulan. Tapis mengikut julat tarikh, susun terkini atau terlama dahulu, dan leret ke kiri pada mana-mana entri untuk memadamnya. Ketik kumpulan untuk membuka Laporan Kumpulannya, atau imbasan tunggal untuk membuka Butiran Rekodnya.",
+
+  "manual_correction_title": "Membetulkan Keputusan AI",
+  "manual_correction_body": "Pada skrin butiran mana-mana foto, ketik tandan yang dikesan untuk menukar gredannya atau membuangnya jika AI tersilap — ini tidak mengubah keputusan asal AI, ia hanya menambah pembetulan anda di atasnya, yang boleh anda buat asal pada bila-bila masa dengan Set Semula. Jika AI terlepas tandan sepenuhnya, gunakan \"Tambah Tandan Terlepas\" untuk meletakkan, menggredkan, dan menyaiz kotak baharu untuknya. Selepas anda membuat sebarang pembetulan, skrin akan menunjukkan \"Pembetulan Anda\" secara lalai dan bukannya keputusan asal AI.",
+
+  "manual_batch_title": "Laporan Kumpulan",
+  "manual_batch_body": "Menunjukkan ringkasan untuk keseluruhan kumpulan imbasan: jumlah bingkai, jumlah pengesanan, purata keyakinan dan masa inferens, serta bar taburan kelas. Jika mana-mana foto dalam kumpulan telah dibetulkan, bar taburan \"Dibetulkan\" kedua akan muncul di sebelah bar AI sendiri, membolehkan anda membandingkannya secara terus.",
+
+  "manual_classes_title": "Kelas Penggredan MPOB",
+  "manual_class_desc_Ripe": "Sedia untuk dituai — gred ideal, dengan keseimbangan buah relai dan warna yang tepat untuk hasil minyak maksimum.",
+  "manual_class_desc_Unripe": "Terlalu awal untuk dituai — buah gelap, belum masak dan tiada buah relai lagi. Biarkan tandan matang dengan lebih lanjut.",
+  "manual_class_desc_Underripe": "Hampir sedia, tetapi belum lagi — buah relai lebih sedikit berbanding tandan yang masak sepenuhnya. Pertimbangkan untuk menunggu beberapa hari lagi.",
+  "manual_class_desc_Overripe": "Dituai lewat daripada masa ideal — kualiti minyak mula merosot semakin lama ia dibiarkan selepas titik ini.",
+  "manual_class_desc_Abnormal": "Menunjukkan kecacatan seperti kerosakan perosak, penyakit, atau pendebungaan lemah — ditanda berasingan daripada penggredan kematangan biasa.",
+  "manual_class_desc_Empty_Bunch": "Kebanyakan buah telah tertanggal daripada tandan — hasil yang tinggal sedikit atau tiada."
+}

+ 166 - 0
assets/lang/zh.json

@@ -0,0 +1,166 @@
+{
+  "app_title": "棕榈油成熟度 AI",
+  "total_scans": "总扫描次数",
+  "analyze_gallery_title": "分析相册",
+  "analyze_gallery_subtitle": "从照片检测成熟度",
+  "snap_analyze_title": "拍照分析",
+  "snap_analyze_subtitle": "手动高清拍摄",
+  "live_inference_title": "实时推理",
+  "live_inference_subtitle": "实时“瞄准扫描”",
+  "history_vault_title": "历史记录",
+  "history_vault_subtitle": "查看以往检测记录",
+  "theme_label": "主题",
+  "language_label": "语言",
+  "settings_label": "设置",
+  "theme_light": "浅色",
+  "theme_dark": "深色",
+  "theme_system": "系统",
+
+  "confidence_label": "置信度:{percent}%",
+  "health_alert_abnormal": "健康警报:检测到异常!",
+  "ai_analyzing": "AI 正在分析...",
+  "retry_button": "重试",
+  "dismiss_button": "关闭",
+  "error_details_label": "错误详情",
+  "done_button": "完成",
+
+  "analysis_appbar_title": "AI 分析",
+  "analysis_init_error": "AI 模型初始化失败:{error}",
+  "analysis_select_photo_hint": "选择照片以开始 AI 分析",
+  "analysis_pick_button": "从相册选择图片",
+  "analysis_bunches_detected": "检测到 {count} 个果串",
+  "analysis_progress_of": "正在分析第 {current} / {total} 张图片",
+  "analysis_progress_stats": "已处理 {processed} · 已跳过 {skipped}",
+  "analysis_optimizing_hint": "正在优化检测参数",
+  "analysis_no_detections_batch": "所选图片均未检测到棕榈果串。",
+
+  "camera_permission_denied": "相机权限被拒绝。",
+  "no_cameras_found": "未找到相机。",
+  "camera_init_error": "相机初始化错误:{error}",
+  "static_appbar_title": "拍照分析",
+  "static_no_detections": "未检测到棕榈果串。",
+  "static_capture_error": "拍摄/分析时出错:{error}",
+  "result_sheet_title": "分析结果",
+  "label_ripeness": "成熟度",
+  "label_confidence": "置信度",
+  "health_alert_title": "健康警报",
+  "health_alert_message": "检测到异常特征,请检查棕榈健康状况。",
+  "yield_warning_title": "产量警告",
+  "yield_warning_message": "过早采收可能导致产量损失。",
+  "acknowledge_button": "知道了",
+
+  "live_no_detections_snackbar": "最终拍摄未检测到棕榈果串。",
+  "live_state_cooldown": "冷却中",
+  "live_state_locking": "锁定中",
+  "live_state_searching": "搜索中...",
+  "live_resuming_scan": "正在恢复扫描...",
+  "live_hold_steady_hint": "保持稳定以锁定目标",
+
+  "cancel_button": "取消",
+  "delete_button": "删除",
+  "reset_button": "重置",
+  "clear_filters_button": "清除筛选",
+
+  "history_reset_title": "重置历史记录",
+  "history_reset_message": "确定要清除所有分析记录吗?此操作无法撤销。",
+  "history_cleared_snackbar": "历史记录已成功清除。",
+  "history_delete_record_confirm": "删除此记录?此操作无法撤销。",
+  "history_delete_batch_confirm": "删除此批次的 {count} 次扫描?此操作无法撤销。",
+  "filter_sort_title": "筛选与排序",
+  "sort_order_label": "排序方式",
+  "sort_latest_first": "最新优先",
+  "sort_oldest_first": "最旧优先",
+  "date_range_label": "日期范围",
+  "all_dates_label": "所有日期",
+  "clear_date_range_tooltip": "清除日期范围",
+  "reset_history_tooltip": "重置历史记录",
+  "history_empty_no_records": "尚未找到任何记录。",
+  "history_empty_no_matches": "没有符合所选筛选条件的记录。",
+  "ungrouped_scans_label": "未分组扫描",
+  "batch_scans_label": "批次 • {count} 次扫描",
+  "majority_label": "主要类别:{className}\n{timeRange}",
+  "record_conf_timestamp": "置信度:{percent}% | {timestamp}",
+
+  "class_Ripe": "熟果",
+  "class_Unripe": "生果",
+  "class_Underripe": "欠熟果",
+  "class_Overripe": "过熟果",
+  "class_Abnormal": "异常果",
+  "class_Empty_Bunch": "空果串",
+
+  "na_label": "不适用",
+  "saving_label": "正在保存...",
+  "ai_result_label": "AI 结果",
+  "added_suffix": " • 已添加",
+
+  "batch_report_title": "批次报告",
+  "batch_no_records": "未找到此批次的记录。",
+  "kpi_frames": "帧数",
+  "kpi_total_detections": "检测总数",
+  "kpi_avg_confidence": "平均置信度",
+  "kpi_avg_inference": "平均推理时间",
+  "kpi_start": "开始",
+  "kpi_end": "结束",
+  "mpob_class_distribution_title": "MPOB 等级分布",
+  "batch_corrected_label": "已修正",
+  "no_detections_in_batch": "此批次中未检测到任何果串。",
+  "frames_corrected_pill": "已修正 {count}/{total} 帧",
+  "frames_section_title": "帧 ({count})",
+
+  "frame_of_title": "第 {current} / {total} 帧",
+  "record_details_title": "记录详情",
+  "your_correction_label": "您的修正",
+  "tap_to_correct_hint": "点击照片上的果串以更正其等级。",
+  "last_corrected_label": "上次修正:{timestamp}",
+  "add_missed_bunch_button": "添加漏检果串",
+  "correction_reset_snackbar": "修正已重置。",
+  "label_file_name": "文件名",
+  "label_primary_grade": "主要等级",
+  "label_inference_time": "推理时间",
+  "label_timestamp": "时间戳",
+  "frame_summary_title": "帧摘要",
+  "analytical_breakdown_title": "分析明细",
+  "corrected_breakdown_title": "修正明细(点击上方果串以编辑或移除)",
+  "industrial_summary_text": "行业摘要:此图像已通过收成质量验证。所有检测到的果串均已按照成熟度标准分类。",
+
+  "correct_grade_title": "更正等级",
+  "remove_detection_button": "移除此检测结果",
+
+  "undo_tooltip": "撤销",
+  "redo_tooltip": "重做",
+  "hide_reference_tooltip": "隐藏 AI 参考框",
+  "show_reference_tooltip": "显示 AI 参考框",
+  "editor_instruction_text": "在漏检的位置点击以放置方框,然后为其评级并拖动调整大小。现有检测结果已淡化显示仅供参考。",
+  "pick_grade_placeholder": "选择等级",
+  "editor_tap_to_add_hint": "点击照片以添加果串",
+  "editor_bunches_added_singular": "已添加 {count} 个果串",
+  "editor_bunches_added_plural": "已添加 {count} 个果串",
+
+  "manual_title": "使用手册",
+
+  "manual_gallery_title": "分析相册",
+  "manual_gallery_body": "从相册中选择一张或多张照片一次性分析。所选的每张照片都会作为一个批次处理——全部处理完成后,您将直接进入该批次的批次报告,一起查看所有帧的结果。",
+
+  "manual_snap_title": "拍照分析",
+  "manual_snap_body": "使用相机的手动快门一次拍摄一张照片。照片分析完成后,结果面板会立即显示检测到的等级和置信度——适合仔细检查单个果串。",
+
+  "manual_live_title": "实时推理",
+  "manual_live_body": "将相机对准果串并保持稳定——应用会在有把握时自动锁定并拍摄,立即显示结果。这只是一个快速验证工具:结果不会保存到历史记录中。如果您想保留记录,请改用拍照分析。",
+
+  "manual_history_title": "历史记录",
+  "manual_history_body": "按批次浏览所有过往扫描记录。可按日期范围筛选,按最新或最旧排序,向左滑动任意条目即可删除。点击批次可打开其批次报告,点击单次扫描可打开其记录详情。",
+
+  "manual_correction_title": "修正 AI 结果",
+  "manual_correction_body": "在任意照片的详情页面,点击检测到的果串即可更改其等级或在 AI 判断错误时将其移除——这不会更改 AI 的原始结果,只会在其基础上添加您的修正,您随时可以通过“重置”撤销。如果 AI 完全漏检了某个果串,使用“添加漏检果串”来放置、评级并调整新方框的大小。一旦您进行了任何修正,屏幕将默认显示“您的修正”而非 AI 的原始结果。",
+
+  "manual_batch_title": "批次报告",
+  "manual_batch_body": "显示整个批次扫描的摘要:帧数、检测总数、平均置信度和推理时间,以及等级分布条形图。如果批次中有任何照片已被修正,AI 自身的分布条旁边会出现第二条“已修正”分布条,方便您直接比较。",
+
+  "manual_classes_title": "MPOB 分级标准",
+  "manual_class_desc_Ripe": "可以采收——理想等级,散落果实比例与颜色达到最佳出油率的平衡。",
+  "manual_class_desc_Unripe": "采收过早——果实颜色深且未成熟,尚无散落果实。应让果串继续成熟。",
+  "manual_class_desc_Underripe": "接近成熟但尚未完全成熟——散落果实比完全成熟的果串少。可考虑再等几天。",
+  "manual_class_desc_Overripe": "采收时间晚于理想时机——超过此阶段后放置越久,油质开始下降。",
+  "manual_class_desc_Abnormal": "存在虫害、病害或授粉不良等缺陷——与正常成熟度分级分开标记。",
+  "manual_class_desc_Empty_Bunch": "大部分果实已从果串脱落——几乎没有剩余产量。"
+}

+ 16 - 7
lib/main.dart

@@ -1,11 +1,15 @@
 import 'package:flutter/material.dart';
 import 'screens/home_screen.dart';
+import 'services/app_strings.dart';
 import 'theme/app_theme.dart';
 import 'theme/theme_controller.dart';
+import 'theme/locale_controller.dart';
 
 void main() async {
   WidgetsFlutterBinding.ensureInitialized();
   await ThemeController.load();
+  await AppStrings.preloadAll();
+  await LocaleController.load();
   runApp(const PalmOilApp());
 }
 
@@ -17,13 +21,18 @@ class PalmOilApp extends StatelessWidget {
     return ValueListenableBuilder<ThemeMode>(
       valueListenable: ThemeController.themeMode,
       builder: (context, mode, _) {
-        return MaterialApp(
-          title: 'Palm Oil Ripeness AI',
-          debugShowCheckedModeBanner: false,
-          theme: AppTheme.light,
-          darkTheme: AppTheme.dark,
-          themeMode: mode,
-          home: const HomeScreen(),
+        return ValueListenableBuilder<String>(
+          valueListenable: LocaleController.localeCode,
+          builder: (context, _, _) {
+            return MaterialApp(
+              title: 'Palm Oil Ripeness AI',
+              debugShowCheckedModeBanner: false,
+              theme: AppTheme.light,
+              darkTheme: AppTheme.dark,
+              themeMode: mode,
+              home: const HomeScreen(),
+            );
+          },
         );
       },
     );

+ 38 - 24
lib/screens/analysis_screen.dart

@@ -8,6 +8,8 @@ import '../services/tflite_service.dart';
 import '../services/database_helper.dart';
 import '../models/palm_record.dart';
 import '../widgets/palm_bounding_box.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 import 'batch_report_screen.dart';
 
 class AnalysisScreen extends StatefulWidget {
@@ -51,7 +53,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
     } catch (e) {
       if (mounted) {
         setState(() {
-          _errorMessage = "Failed to initialize AI model: $e";
+          _errorMessage = AppStrings.tp('analysis_init_error', {'error': '$e'});
         });
       }
     }
@@ -150,7 +152,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
         );
       } else {
         setState(() {
-          _errorMessage = "No palm bunches detected in any selected image.";
+          _errorMessage = AppStrings.t('analysis_no_detections_batch');
         });
       }
     }
@@ -162,9 +164,16 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     final colorScheme = Theme.of(context).colorScheme;
     return Scaffold(
-      appBar: AppBar(title: const Text('AI Analysis')),
+      appBar: AppBar(title: Text(AppStrings.t('analysis_appbar_title'))),
       body: Stack(
         children: [
           Column(
@@ -172,7 +181,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
               Expanded(
                 child: Center(
                   child: _selectedImage == null
-                      ? const Text("Select a photo to start AI analysis")
+                      ? Text(AppStrings.t('analysis_select_photo_hint'))
                       : AspectRatio(
                           aspectRatio: 1.0,
                           child: Padding(
@@ -222,7 +231,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
                 child: ElevatedButton.icon(
                   onPressed: _isAnalyzing ? null : _processGalleryImage,
                   icon: const Icon(Icons.add_a_photo),
-                  label: const Text("Pick Images from Gallery"),
+                  label: Text(AppStrings.t('analysis_pick_button')),
                   style: ElevatedButton.styleFrom(
                     minimumSize: const Size(double.infinity, 54),
                     shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -252,7 +261,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
       decoration: BoxDecoration(
         color: colorScheme.surfaceContainerHighest,
         borderRadius: BorderRadius.circular(12),
-        boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 10)],
+        boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 10)],
       ),
       child: Column(
         children: [
@@ -262,29 +271,29 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
               Icon(Icons.check_circle, color: best.getStatusColor(), size: 32),
               const SizedBox(width: 12),
               Text(
-                best.className,
+                AppStrings.className(best.className),
                 style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
               ),
             ],
           ),
           Text(
-            "Confidence: ${(best.confidence * 100).toStringAsFixed(1)}%",
-            style: TextStyle(fontSize: 14, color: colorScheme.onSurface.withOpacity(0.6)),
+            AppStrings.tp('confidence_label', {'percent': (best.confidence * 100).toStringAsFixed(1)}),
+            style: TextStyle(fontSize: 14, color: colorScheme.onSurface.withValues(alpha: 0.6)),
           ),
           if (_isHealthAlert(best.className))
-            const Padding(
-              padding: EdgeInsets.only(top: 8.0),
+            Padding(
+              padding: const EdgeInsets.only(top: 8.0),
               child: Text(
-                "HEALTH ALERT: Abnormal detected!",
-                style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
+                AppStrings.t('health_alert_abnormal'),
+                style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
               ),
             ),
           if (_detections!.length > 1)
             Padding(
               padding: const EdgeInsets.only(top: 8.0),
               child: Text(
-                "${_detections!.length} bunches detected",
-                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.6)),
+                AppStrings.tp('analysis_bunches_detected', {'count': '${_detections!.length}'}),
+                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.6)),
               ),
             ),
         ],
@@ -298,7 +307,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
       child: BackdropFilter(
         filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
         child: Container(
-          color: Colors.black.withOpacity(0.3),
+          color: Colors.black.withValues(alpha: 0.3),
           child: Center(
             child: Column(
               mainAxisSize: MainAxisSize.min,
@@ -310,8 +319,11 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
                 const SizedBox(height: 24),
                 Text(
                   isBatch
-                      ? "Analyzing image ${(_batchProcessed + _batchSkipped) + 1} of $_batchTotal"
-                      : "AI is Analyzing...",
+                      ? AppStrings.tp('analysis_progress_of', {
+                          'current': '${(_batchProcessed + _batchSkipped) + 1}',
+                          'total': '$_batchTotal',
+                        })
+                      : AppStrings.t('ai_analyzing'),
                   style: const TextStyle(
                     color: Colors.white,
                     fontSize: 22,
@@ -320,7 +332,9 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
                   ),
                 ),
                 Text(
-                  isBatch ? "$_batchProcessed processed · $_batchSkipped skipped" : "Optimizing detection parameters",
+                  isBatch
+                      ? AppStrings.tp('analysis_progress_stats', {'processed': '$_batchProcessed', 'skipped': '$_batchSkipped'})
+                      : AppStrings.t('analysis_optimizing_hint'),
                   style: const TextStyle(color: Colors.white70, fontSize: 14),
                 ),
               ],
@@ -342,7 +356,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
         decoration: BoxDecoration(
           color: colorScheme.error,
           borderRadius: BorderRadius.circular(12),
-          boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 8)],
+          boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8)],
         ),
         child: Column(
           mainAxisSize: MainAxisSize.min,
@@ -352,7 +366,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
               children: [
                 Icon(Icons.error_outline, color: colorScheme.onError),
                 const SizedBox(width: 8),
-                Text("Error Details", style: TextStyle(color: colorScheme.onError, fontWeight: FontWeight.bold)),
+                Text(AppStrings.t('error_details_label'), style: TextStyle(color: colorScheme.onError, fontWeight: FontWeight.bold)),
               ],
             ),
             const SizedBox(height: 8),
@@ -363,7 +377,7 @@ class _AnalysisScreenState extends State<AnalysisScreen> with SingleTickerProvid
             const SizedBox(height: 8),
             TextButton(
               onPressed: () => setState(() => _errorMessage = null),
-              child: Text("Dismiss", style: TextStyle(color: colorScheme.onError)),
+              child: Text(AppStrings.t('dismiss_button'), style: TextStyle(color: colorScheme.onError)),
             ),
           ],
         ),
@@ -402,13 +416,13 @@ class _ScanningOverlay extends StatelessWidget {
                   gradient: LinearGradient(
                     colors: [
                       Colors.transparent,
-                      color.withOpacity(0.8),
+                      color.withValues(alpha: 0.8),
                       Colors.transparent,
                     ],
                   ),
                   boxShadow: [
                     BoxShadow(
-                      color: color.withOpacity(0.6),
+                      color: color.withValues(alpha: 0.6),
                       blurRadius: 15,
                       spreadRadius: 2,
                     ),

+ 33 - 24
lib/screens/batch_report_screen.dart

@@ -5,6 +5,8 @@ import '../models/palm_record.dart';
 import '../models/palm_correction.dart';
 import '../constants/mpob_colors.dart';
 import '../theme/app_theme.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 import 'record_detail_screen.dart';
 
 class BatchReportScreen extends StatefulWidget {
@@ -86,6 +88,13 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     final colorScheme = Theme.of(context).colorScheme;
     if (_isLoading) {
       return const Scaffold(body: Center(child: CircularProgressIndicator()));
@@ -93,8 +102,8 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
 
     if (_records.isEmpty) {
       return Scaffold(
-        appBar: AppBar(title: const Text('Batch Report')),
-        body: const Center(child: Text('No records found for this batch.')),
+        appBar: AppBar(title: Text(AppStrings.t('batch_report_title'))),
+        body: Center(child: Text(AppStrings.t('batch_no_records'))),
       );
     }
 
@@ -104,18 +113,18 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
     final totalTally = tally.values.fold(0, (a, b) => a + b);
 
     return Scaffold(
-      appBar: AppBar(title: const Text('Batch Report')),
+      appBar: AppBar(title: Text(AppStrings.t('batch_report_title'))),
       body: ListView(
         padding: const EdgeInsets.all(16),
         children: [
           _buildKpiGrid(start, end, colorScheme),
           const SizedBox(height: 24),
-          Text("MPOB Class Distribution", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+          Text(AppStrings.t('mpob_class_distribution_title'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
           const SizedBox(height: 4),
-          Text("AI Result", style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colorScheme.onSurface.withOpacity(0.6))),
+          Text(AppStrings.t('ai_result_label'), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colorScheme.onSurface.withValues(alpha: 0.6))),
           const SizedBox(height: 8),
           if (totalTally == 0)
-            Text("No detections in this batch.", style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6)))
+            Text(AppStrings.t('no_detections_in_batch'), style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6)))
           else ...[
             _buildTallyBar(tally, totalTally),
             const SizedBox(height: 12),
@@ -125,16 +134,16 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
             const SizedBox(height: 24),
             Row(
               children: [
-                Text("Corrected", style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colorScheme.onSurface.withOpacity(0.6))),
+                Text(AppStrings.t('batch_corrected_label'), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colorScheme.onSurface.withValues(alpha: 0.6))),
                 const SizedBox(width: 8),
                 Container(
                   padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                   decoration: BoxDecoration(
-                    color: colorScheme.primary.withOpacity(0.12),
+                    color: colorScheme.primary.withValues(alpha: 0.12),
                     borderRadius: BorderRadius.circular(20),
                   ),
                   child: Text(
-                    "$_correctedFrameCount of ${_records.length} frames corrected",
+                    AppStrings.tp('frames_corrected_pill', {'count': '$_correctedFrameCount', 'total': '${_records.length}'}),
                     style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colorScheme.primary),
                   ),
                 ),
@@ -145,7 +154,7 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
               final correctedTally = _correctedClassTally;
               final correctedTotal = correctedTally.values.fold(0, (a, b) => a + b);
               if (correctedTotal == 0) {
-                return Text("No detections in this batch.", style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6)));
+                return Text(AppStrings.t('no_detections_in_batch'), style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6)));
               }
               return Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
@@ -158,7 +167,7 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
             }),
           ],
           const SizedBox(height: 24),
-          Text("Frames (${_records.length})", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+          Text(AppStrings.tp('frames_section_title', {'count': '${_records.length}'}), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
           const SizedBox(height: 12),
           _buildFrameGrid(),
         ],
@@ -177,18 +186,18 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
       crossAxisSpacing: 12,
       childAspectRatio: 2.2,
       children: [
-        _kpiCard("Frames", "${_records.length}", Icons.photo_library, accents[0], colorScheme),
-        _kpiCard("Total Detections", "$_totalDetections", Icons.center_focus_strong, accents[1], colorScheme),
-        _kpiCard("Avg Confidence", "${(_avgConfidence * 100).toStringAsFixed(1)}%", Icons.verified, accents[2], colorScheme),
+        _kpiCard(AppStrings.t('kpi_frames'), "${_records.length}", Icons.photo_library, accents[0], colorScheme),
+        _kpiCard(AppStrings.t('kpi_total_detections'), "$_totalDetections", Icons.center_focus_strong, accents[1], colorScheme),
+        _kpiCard(AppStrings.t('kpi_avg_confidence'), "${(_avgConfidence * 100).toStringAsFixed(1)}%", Icons.verified, accents[2], colorScheme),
         _kpiCard(
-          "Avg Inference",
-          avgInference == null ? "N/A" : "${avgInference.toStringAsFixed(0)} ms",
+          AppStrings.t('kpi_avg_inference'),
+          avgInference == null ? AppStrings.t('na_label') : "${avgInference.toStringAsFixed(0)} ms",
           Icons.speed,
           accents[3],
           colorScheme,
         ),
-        _kpiCard("Start", _formatTime(start), Icons.play_circle_outline, accents[4], colorScheme),
-        _kpiCard("End", _formatTime(end), Icons.stop_circle_outlined, accents[4], colorScheme),
+        _kpiCard(AppStrings.t('kpi_start'), _formatTime(start), Icons.play_circle_outline, accents[4], colorScheme),
+        _kpiCard(AppStrings.t('kpi_end'), _formatTime(end), Icons.stop_circle_outlined, accents[4], colorScheme),
       ],
     );
   }
@@ -199,8 +208,8 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
       decoration: BoxDecoration(
         color: colorScheme.surfaceContainerHighest,
         borderRadius: BorderRadius.circular(12),
-        border: Border.all(color: color.withOpacity(0.2)),
-        boxShadow: [BoxShadow(color: color.withOpacity(0.08), blurRadius: 8, offset: const Offset(0, 4))],
+        border: Border.all(color: color.withValues(alpha: 0.2)),
+        boxShadow: [BoxShadow(color: color.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 4))],
       ),
       child: Row(
         children: [
@@ -212,7 +221,7 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
               mainAxisSize: MainAxisSize.min,
               children: [
                 Text(value, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: colorScheme.onSurface), overflow: TextOverflow.ellipsis),
-                Text(label, style: TextStyle(fontSize: 11, color: colorScheme.onSurface.withOpacity(0.6))),
+                Text(label, style: TextStyle(fontSize: 11, color: colorScheme.onSurface.withValues(alpha: 0.6))),
               ],
             ),
           ),
@@ -252,7 +261,7 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
           children: [
             Container(width: 12, height: 12, decoration: BoxDecoration(color: getMPOBColor(className), shape: BoxShape.circle)),
             const SizedBox(width: 6),
-            Text("$className: $count ($pct%)", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)),
+            Text("${AppStrings.className(className)}: $count ($pct%)", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)),
           ],
         );
       }).toList(),
@@ -293,9 +302,9 @@ class _BatchReportScreenState extends State<BatchReportScreen> {
                   bottom: 0,
                   child: Container(
                     padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
-                    color: color.withOpacity(0.85),
+                    color: color.withValues(alpha: 0.85),
                     child: Text(
-                      record.ripenessClass,
+                      AppStrings.className(record.ripenessClass),
                       style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
                       overflow: TextOverflow.ellipsis,
                     ),

+ 24 - 13
lib/screens/detection_editor_screen.dart

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
 import '../constants/mpob_colors.dart';
 import '../widgets/palm_bounding_box.dart';
 import '../widgets/class_picker_sheet.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 import 'record_detail_screen.dart' show DetectionData;
 
 enum _HandlePosition { topLeft, topRight, bottomLeft, bottomRight }
@@ -168,24 +170,31 @@ class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     final colorScheme = Theme.of(context).colorScheme;
 
     return Scaffold(
       appBar: AppBar(
-        title: const Text("Add Missed Bunch"),
+        title: Text(AppStrings.t('add_missed_bunch_button')),
         actions: [
           IconButton(
-            tooltip: "Undo",
+            tooltip: AppStrings.t('undo_tooltip'),
             onPressed: _canUndo ? _undo : null,
             icon: const Icon(Icons.undo),
           ),
           IconButton(
-            tooltip: "Redo",
+            tooltip: AppStrings.t('redo_tooltip'),
             onPressed: _canRedo ? _redo : null,
             icon: const Icon(Icons.redo),
           ),
           IconButton(
-            tooltip: _showReference ? "Hide AI reference boxes" : "Show AI reference boxes",
+            tooltip: _showReference ? AppStrings.t('hide_reference_tooltip') : AppStrings.t('show_reference_tooltip'),
             onPressed: () => setState(() => _showReference = !_showReference),
             icon: Icon(_showReference ? Icons.visibility_outlined : Icons.visibility_off_outlined),
           ),
@@ -196,8 +205,8 @@ class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
           Padding(
             padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
             child: Text(
-              "Tap anywhere a bunch was missed to place a box, then grade it and drag to fit. Existing detections are dimmed for reference.",
-              style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.7)),
+              AppStrings.t('editor_instruction_text'),
+              style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.7)),
             ),
           ),
           Expanded(
@@ -253,7 +262,7 @@ class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
                             },
                           ),
                         )
-                      : Center(child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withOpacity(0.4))),
+                      : Center(child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.4))),
                 ),
               ),
             ),
@@ -277,9 +286,9 @@ class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
               icon: Icon(
                 Icons.circle,
                 size: 12,
-                color: box.className == null ? colorScheme.onSurface.withOpacity(0.4) : getMPOBColor(box.className!),
+                color: box.className == null ? colorScheme.onSurface.withValues(alpha: 0.4) : getMPOBColor(box.className!),
               ),
-              label: Text(box.className ?? "Pick grade"),
+              label: Text(box.className == null ? AppStrings.t('pick_grade_placeholder') : AppStrings.className(box.className!)),
             ),
           ),
           const SizedBox(width: 8),
@@ -311,9 +320,11 @@ class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
             ),
             Expanded(
               child: Text(
-                count == 0 ? "Tap the photo to add a bunch" : "$count bunch${count == 1 ? '' : 'es'} added",
+                count == 0
+                    ? AppStrings.t('editor_tap_to_add_hint')
+                    : AppStrings.tp(count == 1 ? 'editor_bunches_added_singular' : 'editor_bunches_added_plural', {'count': '$count'}),
                 textAlign: TextAlign.center,
-                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.7)),
+                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.7)),
               ),
             ),
             IconButton.filled(
@@ -321,7 +332,7 @@ class _DetectionEditorScreenState extends State<DetectionEditorScreen> {
               style: IconButton.styleFrom(
                 backgroundColor: colorScheme.primary,
                 foregroundColor: colorScheme.onPrimary,
-                disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12),
+                disabledBackgroundColor: colorScheme.onSurface.withValues(alpha: 0.12),
                 minimumSize: const Size(52, 52),
               ),
               icon: const Icon(Icons.check),
@@ -392,7 +403,7 @@ class _DraggableBox extends StatelessWidget {
                         padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
                         color: color,
                         child: Text(
-                          box.className!,
+                          AppStrings.className(box.className!),
                           style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
                         ),
                       ),

+ 44 - 30
lib/screens/history_screen.dart

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
 import '../services/database_helper.dart';
 import '../models/palm_record.dart';
 import '../constants/mpob_colors.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 import 'batch_report_screen.dart';
 import 'record_detail_screen.dart';
 
@@ -93,13 +95,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
     final confirm = await showDialog<bool>(
       context: context,
       builder: (context) => AlertDialog(
-        title: const Text("Reset History"),
-        content: const Text("Are you sure you want to clear all analysis records? This action is irreversible."),
+        title: Text(AppStrings.t('history_reset_title')),
+        content: Text(AppStrings.t('history_reset_message')),
         actions: [
-          TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
+          TextButton(onPressed: () => Navigator.pop(context, false), child: Text(AppStrings.t('cancel_button'))),
           TextButton(
             onPressed: () => Navigator.pop(context, true),
-            child: Text("Reset", style: TextStyle(color: errorColor)),
+            child: Text(AppStrings.t('reset_button'), style: TextStyle(color: errorColor)),
           ),
         ],
       ),
@@ -110,7 +112,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
       _loadHistory();
       if (mounted) {
         ScaffoldMessenger.of(context).showSnackBar(
-          const SnackBar(content: Text("History cleared successfully.")),
+          SnackBar(content: Text(AppStrings.t('history_cleared_snackbar'))),
         );
       }
     }
@@ -121,13 +123,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
     final confirm = await showDialog<bool>(
       context: context,
       builder: (context) => AlertDialog(
-        title: const Text("Delete"),
+        title: Text(AppStrings.t('delete_button')),
         content: Text(message),
         actions: [
-          TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
+          TextButton(onPressed: () => Navigator.pop(context, false), child: Text(AppStrings.t('cancel_button'))),
           TextButton(
             onPressed: () => Navigator.pop(context, true),
-            child: Text("Delete", style: TextStyle(color: errorColor)),
+            child: Text(AppStrings.t('delete_button'), style: TextStyle(color: errorColor)),
           ),
         ],
       ),
@@ -165,24 +167,24 @@ class _HistoryScreenState extends State<HistoryScreen> {
                   Row(
                     mainAxisAlignment: MainAxisAlignment.spaceBetween,
                     children: [
-                      const Text("Filter & Sort", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+                      Text(AppStrings.t('filter_sort_title'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
                       TextButton(
                         onPressed: () {
                           _clearFilters();
                           setSheetState(() {});
                         },
-                        child: const Text("Reset"),
+                        child: Text(AppStrings.t('reset_button')),
                       ),
                     ],
                   ),
                   const SizedBox(height: 16),
-                  const Text("Sort Order", style: TextStyle(fontWeight: FontWeight.w600)),
+                  Text(AppStrings.t('sort_order_label'), style: const TextStyle(fontWeight: FontWeight.w600)),
                   const SizedBox(height: 8),
                   Row(
                     children: [
                       Expanded(
                         child: ChoiceChip(
-                          label: const Text("Latest First"),
+                          label: Text(AppStrings.t('sort_latest_first')),
                           selected: !_sortAscending,
                           selectedColor: colorScheme.primary,
                           labelStyle: TextStyle(color: !_sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
@@ -196,7 +198,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
                       const SizedBox(width: 12),
                       Expanded(
                         child: ChoiceChip(
-                          label: const Text("Oldest First"),
+                          label: Text(AppStrings.t('sort_oldest_first')),
                           selected: _sortAscending,
                           selectedColor: colorScheme.primary,
                           labelStyle: TextStyle(color: _sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
@@ -210,7 +212,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
                     ],
                   ),
                   const SizedBox(height: 24),
-                  const Text("Date Range", style: TextStyle(fontWeight: FontWeight.w600)),
+                  Text(AppStrings.t('date_range_label'), style: const TextStyle(fontWeight: FontWeight.w600)),
                   const SizedBox(height: 8),
                   Row(
                     children: [
@@ -219,7 +221,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
                           icon: const Icon(Icons.date_range),
                           label: Text(
                             _dateRange == null
-                                ? "All Dates"
+                                ? AppStrings.t('all_dates_label')
                                 : "${_formatDate(_dateRange!.start)} – ${_formatDate(_dateRange!.end)}",
                           ),
                           onPressed: () async {
@@ -241,7 +243,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
                       if (_dateRange != null)
                         IconButton(
                           icon: const Icon(Icons.clear),
-                          tooltip: 'Clear date range',
+                          tooltip: AppStrings.t('clear_date_range_tooltip'),
                           onPressed: () {
                             setState(() => _dateRange = null);
                             setSheetState(() {});
@@ -264,19 +266,26 @@ class _HistoryScreenState extends State<HistoryScreen> {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     return Scaffold(
       appBar: AppBar(
-        title: const Text('History Vault'),
+        title: Text(AppStrings.t('history_vault_title')),
         actions: [
           IconButton(
             icon: Icon(_hasActiveFilter ? Icons.filter_alt : Icons.filter_list),
-            tooltip: 'Filter & Sort',
+            tooltip: AppStrings.t('filter_sort_title'),
             onPressed: _openFilterSheet,
           ),
           if (_allGroups.isNotEmpty)
             IconButton(
               icon: const Icon(Icons.delete_sweep),
-              tooltip: 'Reset History',
+              tooltip: AppStrings.t('reset_history_tooltip'),
               onPressed: _resetHistory,
             ),
         ],
@@ -295,15 +304,15 @@ class _HistoryScreenState extends State<HistoryScreen> {
 
   Widget _buildEmptyState() {
     if (_allGroups.isEmpty) {
-      return const Center(child: Text("No records found yet."));
+      return Center(child: Text(AppStrings.t('history_empty_no_records')));
     }
     return Center(
       child: Column(
         mainAxisSize: MainAxisSize.min,
         children: [
-          const Text("No records match the selected filters."),
+          Text(AppStrings.t('history_empty_no_matches')),
           const SizedBox(height: 12),
-          TextButton(onPressed: _clearFilters, child: const Text("Clear Filters")),
+          TextButton(onPressed: _clearFilters, child: Text(AppStrings.t('clear_filters_button'))),
         ],
       ),
     );
@@ -332,7 +341,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
         key: key,
         direction: DismissDirection.endToStart,
         background: _buildDeleteBackground(),
-        confirmDismiss: (_) => _confirmDelete("Delete this record? This action is irreversible."),
+        confirmDismiss: (_) => _confirmDelete(AppStrings.t('history_delete_record_confirm')),
         onDismissed: (_) => _deleteGroup(group),
         child: _buildRecordTile(group.records.first),
       );
@@ -349,7 +358,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
       key: key,
       direction: DismissDirection.endToStart,
       background: _buildDeleteBackground(),
-      confirmDismiss: (_) => _confirmDelete("Delete this batch of ${group.records.length} scans? This action is irreversible."),
+      confirmDismiss: (_) => _confirmDelete(AppStrings.tp('history_delete_batch_confirm', {'count': '${group.records.length}'})),
       onDismissed: (_) => _deleteGroup(group),
       child: Card(
         margin: const EdgeInsets.only(bottom: 12),
@@ -357,15 +366,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
         shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
         child: ListTile(
           leading: CircleAvatar(
-            backgroundColor: statusColor.withOpacity(0.2),
+            backgroundColor: statusColor.withValues(alpha: 0.2),
             child: Icon(Icons.collections_bookmark, color: statusColor),
           ),
           title: Text(
-            group.batchId == null ? "Ungrouped Scans" : "Batch • ${group.records.length} scans",
+            group.batchId == null
+                ? AppStrings.t('ungrouped_scans_label')
+                : AppStrings.tp('batch_scans_label', {'count': '${group.records.length}'}),
             style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
           ),
           subtitle: Text(
-            "Majority: ${group.dominantClass}\n$timeRange",
+            AppStrings.tp('majority_label', {'className': AppStrings.className(group.dominantClass), 'timeRange': timeRange}),
             style: const TextStyle(fontSize: 12),
           ),
           trailing: const Icon(Icons.chevron_right),
@@ -389,15 +400,18 @@ class _HistoryScreenState extends State<HistoryScreen> {
       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
       child: ListTile(
         leading: CircleAvatar(
-          backgroundColor: statusColor.withOpacity(0.2),
+          backgroundColor: statusColor.withValues(alpha: 0.2),
           child: Icon(Icons.spa, color: statusColor),
         ),
         title: Text(
-          record.ripenessClass,
+          AppStrings.className(record.ripenessClass),
           style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
         ),
         subtitle: Text(
-          "Conf: ${(record.confidence * 100).toStringAsFixed(1)}% | ${record.timestamp.toString().split('.')[0]}",
+          AppStrings.tp('record_conf_timestamp', {
+            'percent': (record.confidence * 100).toStringAsFixed(1),
+            'timestamp': record.timestamp.toString().split('.')[0],
+          }),
           style: const TextStyle(fontSize: 12),
         ),
         trailing: const Icon(Icons.chevron_right),

+ 232 - 101
lib/screens/home_screen.dart

@@ -1,11 +1,14 @@
 import 'package:flutter/material.dart';
 import '../services/database_helper.dart';
+import '../services/app_strings.dart';
 import '../theme/theme_controller.dart';
+import '../theme/locale_controller.dart';
 import '../widgets/brand_mark.dart';
 import 'analysis_screen.dart';
 import 'live_analysis_screen.dart';
 import 'static_capture_screen.dart';
 import 'history_screen.dart';
+import 'manual_screen.dart';
 
 class HomeScreen extends StatefulWidget {
   const HomeScreen({super.key});
@@ -32,108 +35,194 @@ class _HomeScreenState extends State<HomeScreen> {
   Widget build(BuildContext context) {
     final colorScheme = Theme.of(context).colorScheme;
 
-    return Scaffold(
-      body: SafeArea(
-        child: SingleChildScrollView(
-          padding: const EdgeInsets.all(20),
-          child: Column(
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: [
-              Row(
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) {
+        return Scaffold(
+          body: SafeArea(
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.all(20),
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
-                  const BrandMark(size: 44),
-                  const SizedBox(width: 12),
-                  Expanded(
-                    child: Text(
-                      'Palm Oil Ripeness AI',
-                      style: TextStyle(
-                        fontSize: 20,
-                        fontWeight: FontWeight.bold,
-                        color: colorScheme.onSurface,
+                  Row(
+                    children: [
+                      const BrandMark(size: 44),
+                      const SizedBox(width: 12),
+                      Expanded(
+                        child: Text(
+                          AppStrings.t('app_title'),
+                          style: TextStyle(
+                            fontSize: 20,
+                            fontWeight: FontWeight.bold,
+                            color: colorScheme.onSurface,
+                          ),
+                        ),
                       ),
-                    ),
+                      IconButton(
+                        icon: Icon(Icons.help_outline, color: colorScheme.onSurface),
+                        tooltip: AppStrings.t('manual_title'),
+                        onPressed: () => Navigator.push(
+                          context,
+                          MaterialPageRoute(builder: (context) => const ManualScreen()),
+                        ),
+                      ),
+                      IconButton(
+                        icon: Icon(Icons.settings_outlined, color: colorScheme.onSurface),
+                        tooltip: AppStrings.t('settings_label'),
+                        onPressed: () => _showSettingsSheet(context),
+                      ),
+                    ],
                   ),
-                  ValueListenableBuilder<ThemeMode>(
-                    valueListenable: ThemeController.themeMode,
-                    builder: (context, mode, _) {
-                      return IconButton(
-                        icon: Icon(_themeModeIcon(mode), color: colorScheme.onSurface),
-                        tooltip: 'Theme',
-                        onPressed: () => _showThemePicker(context, mode),
-                      );
-                    },
+                  const SizedBox(height: 20),
+                  _buildStatCard(colorScheme),
+                  const SizedBox(height: 24),
+                  GridView.count(
+                    shrinkWrap: true,
+                    physics: const NeverScrollableScrollPhysics(),
+                    crossAxisCount: 2,
+                    mainAxisSpacing: 16,
+                    crossAxisSpacing: 16,
+                    childAspectRatio: 0.95,
+                    children: [
+                      _buildTile(
+                        context,
+                        title: AppStrings.t('analyze_gallery_title'),
+                        subtitle: AppStrings.t('analyze_gallery_subtitle'),
+                        icon: Icons.photo_library,
+                        background: colorScheme.primary,
+                        foreground: colorScheme.onPrimary,
+                        onTap: () => Navigator.push(
+                          context,
+                          MaterialPageRoute(builder: (context) => const AnalysisScreen()),
+                        ),
+                      ),
+                      _buildTile(
+                        context,
+                        title: AppStrings.t('snap_analyze_title'),
+                        subtitle: AppStrings.t('snap_analyze_subtitle'),
+                        icon: Icons.camera_alt,
+                        background: colorScheme.secondary,
+                        foreground: colorScheme.onSecondary,
+                        onTap: () => Navigator.push(
+                          context,
+                          MaterialPageRoute(builder: (context) => const StaticCaptureScreen()),
+                        ),
+                      ),
+                      _buildTile(
+                        context,
+                        title: AppStrings.t('live_inference_title'),
+                        subtitle: AppStrings.t('live_inference_subtitle'),
+                        icon: Icons.camera,
+                        background: colorScheme.primaryContainer,
+                        foreground: colorScheme.onPrimaryContainer,
+                        onTap: () => Navigator.push(
+                          context,
+                          MaterialPageRoute(builder: (context) => const LiveAnalysisScreen()),
+                        ),
+                      ),
+                      _buildTile(
+                        context,
+                        title: AppStrings.t('history_vault_title'),
+                        subtitle: AppStrings.t('history_vault_subtitle'),
+                        icon: Icons.history,
+                        background: colorScheme.surfaceContainerHighest,
+                        foreground: colorScheme.onSurface,
+                        bordered: true,
+                        onTap: () => Navigator.push(
+                          context,
+                          MaterialPageRoute(builder: (context) => const HistoryScreen()),
+                        ),
+                      ),
+                    ],
                   ),
                 ],
               ),
-              const SizedBox(height: 20),
-              _buildStatCard(colorScheme),
-              const SizedBox(height: 24),
-              GridView.count(
-                shrinkWrap: true,
-                physics: const NeverScrollableScrollPhysics(),
-                crossAxisCount: 2,
-                mainAxisSpacing: 16,
-                crossAxisSpacing: 16,
-                childAspectRatio: 0.95,
-                children: [
-                  _buildTile(
-                    context,
-                    title: 'Analyze Gallery',
-                    subtitle: 'Detect ripeness from photos',
-                    icon: Icons.photo_library,
-                    background: colorScheme.primary,
-                    foreground: colorScheme.onPrimary,
-                    onTap: () => Navigator.push(
-                      context,
-                      MaterialPageRoute(builder: (context) => const AnalysisScreen()),
-                    ),
-                  ),
-                  _buildTile(
-                    context,
-                    title: 'Snap & Analyze',
-                    subtitle: 'Manual high-res capture',
-                    icon: Icons.camera_alt,
-                    background: colorScheme.secondary,
-                    foreground: colorScheme.onSecondary,
-                    onTap: () => Navigator.push(
-                      context,
-                      MaterialPageRoute(builder: (context) => const StaticCaptureScreen()),
-                    ),
-                  ),
-                  _buildTile(
-                    context,
-                    title: 'Live Inference',
-                    subtitle: 'Real-time "Point-and-Scan"',
-                    icon: Icons.camera,
-                    background: colorScheme.primaryContainer,
-                    foreground: colorScheme.onPrimaryContainer,
-                    onTap: () => Navigator.push(
-                      context,
-                      MaterialPageRoute(builder: (context) => const LiveAnalysisScreen()),
-                    ),
-                  ),
-                  _buildTile(
-                    context,
-                    title: 'History Vault',
-                    subtitle: 'View previous detections',
-                    icon: Icons.history,
-                    background: colorScheme.surfaceContainerHighest,
-                    foreground: colorScheme.onSurface,
-                    bordered: true,
-                    onTap: () => Navigator.push(
-                      context,
-                      MaterialPageRoute(builder: (context) => const HistoryScreen()),
-                    ),
-                  ),
-                ],
+            ),
+          ),
+        );
+      },
+    );
+  }
+
+  Future<void> _showSettingsSheet(BuildContext context) async {
+    await showModalBottomSheet(
+      context: context,
+      shape: const RoundedRectangleBorder(
+        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+      ),
+      builder: (sheetContext) {
+        return SafeArea(
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Padding(
+                padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
+                child: Align(
+                  alignment: Alignment.centerLeft,
+                  child: Text(AppStrings.t('settings_label'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
+                ),
+              ),
+              ValueListenableBuilder<ThemeMode>(
+                valueListenable: ThemeController.themeMode,
+                builder: (context, mode, _) {
+                  return ListTile(
+                    leading: Icon(_themeModeIcon(mode)),
+                    title: Text(AppStrings.t('theme_label')),
+                    subtitle: Text(_themeModeLabel(mode)),
+                    trailing: const Icon(Icons.chevron_right),
+                    onTap: () {
+                      Navigator.pop(sheetContext);
+                      _showThemePicker(context, mode);
+                    },
+                  );
+                },
               ),
+              ValueListenableBuilder<String>(
+                valueListenable: LocaleController.localeCode,
+                builder: (context, code, _) {
+                  return ListTile(
+                    leading: const Icon(Icons.language),
+                    title: Text(AppStrings.t('language_label')),
+                    subtitle: Text(_languageLabel(code)),
+                    trailing: const Icon(Icons.chevron_right),
+                    onTap: () {
+                      Navigator.pop(sheetContext);
+                      _showLanguagePicker(context);
+                    },
+                  );
+                },
+              ),
+              const SizedBox(height: 8),
             ],
           ),
-        ),
-      ),
+        );
+      },
     );
   }
 
+  String _themeModeLabel(ThemeMode mode) {
+    switch (mode) {
+      case ThemeMode.light:
+        return AppStrings.t('theme_light');
+      case ThemeMode.dark:
+        return AppStrings.t('theme_dark');
+      case ThemeMode.system:
+        return AppStrings.t('theme_system');
+    }
+  }
+
+  String _languageLabel(String code) {
+    switch (code) {
+      case 'ms':
+        return "Bahasa Melayu";
+      case 'zh':
+        return "中文";
+      default:
+        return "English";
+    }
+  }
+
   IconData _themeModeIcon(ThemeMode mode) {
     switch (mode) {
       case ThemeMode.light:
@@ -156,16 +245,16 @@ class _HomeScreenState extends State<HomeScreen> {
           child: Column(
             mainAxisSize: MainAxisSize.min,
             children: [
-              const Padding(
-                padding: EdgeInsets.fromLTRB(20, 20, 20, 8),
+              Padding(
+                padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
                 child: Align(
                   alignment: Alignment.centerLeft,
-                  child: Text("Theme", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
+                  child: Text(AppStrings.t('theme_label'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                 ),
               ),
-              _themeOptionTile(sheetContext, "Light", Icons.light_mode, ThemeMode.light, current),
-              _themeOptionTile(sheetContext, "Dark", Icons.dark_mode, ThemeMode.dark, current),
-              _themeOptionTile(sheetContext, "System", Icons.brightness_auto, ThemeMode.system, current),
+              _themeOptionTile(sheetContext, AppStrings.t('theme_light'), Icons.light_mode, ThemeMode.light, current),
+              _themeOptionTile(sheetContext, AppStrings.t('theme_dark'), Icons.dark_mode, ThemeMode.dark, current),
+              _themeOptionTile(sheetContext, AppStrings.t('theme_system'), Icons.brightness_auto, ThemeMode.system, current),
               const SizedBox(height: 8),
             ],
           ),
@@ -187,6 +276,48 @@ class _HomeScreenState extends State<HomeScreen> {
     );
   }
 
+  Future<void> _showLanguagePicker(BuildContext context) async {
+    final current = LocaleController.localeCode.value;
+    await showModalBottomSheet(
+      context: context,
+      shape: const RoundedRectangleBorder(
+        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+      ),
+      builder: (sheetContext) {
+        return SafeArea(
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Padding(
+                padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
+                child: Align(
+                  alignment: Alignment.centerLeft,
+                  child: Text(AppStrings.t('language_label'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
+                ),
+              ),
+              _languageOptionTile(sheetContext, "English", "en", current),
+              _languageOptionTile(sheetContext, "Bahasa Melayu", "ms", current),
+              _languageOptionTile(sheetContext, "中文", "zh", current),
+              const SizedBox(height: 8),
+            ],
+          ),
+        );
+      },
+    );
+  }
+
+  Widget _languageOptionTile(BuildContext context, String label, String code, String current) {
+    final selected = code == current;
+    return ListTile(
+      title: Text(label),
+      trailing: selected ? const Icon(Icons.check) : null,
+      onTap: () {
+        LocaleController.setLocale(code);
+        Navigator.pop(context);
+      },
+    );
+  }
+
   Widget _buildStatCard(ColorScheme colorScheme) {
     return Container(
       width: double.infinity,
@@ -194,7 +325,7 @@ class _HomeScreenState extends State<HomeScreen> {
       decoration: BoxDecoration(
         color: colorScheme.surfaceContainerHighest,
         borderRadius: BorderRadius.circular(16),
-        border: Border.all(color: colorScheme.primary.withOpacity(0.15)),
+        border: Border.all(color: colorScheme.primary.withValues(alpha: 0.15)),
       ),
       child: Row(
         children: [
@@ -208,8 +339,8 @@ class _HomeScreenState extends State<HomeScreen> {
                 style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
               ),
               Text(
-                'Total Scans',
-                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withOpacity(0.6)),
+                AppStrings.t('total_scans'),
+                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.6)),
               ),
             ],
           ),
@@ -235,7 +366,7 @@ class _HomeScreenState extends State<HomeScreen> {
         decoration: BoxDecoration(
           color: background,
           borderRadius: BorderRadius.circular(20),
-          border: bordered ? Border.all(color: foreground.withOpacity(0.15)) : null,
+          border: bordered ? Border.all(color: foreground.withValues(alpha: 0.15)) : null,
         ),
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
@@ -252,7 +383,7 @@ class _HomeScreenState extends State<HomeScreen> {
                 const SizedBox(height: 4),
                 Text(
                   subtitle,
-                  style: TextStyle(fontSize: 12, color: foreground.withOpacity(0.85)),
+                  style: TextStyle(fontSize: 12, color: foreground.withValues(alpha: 0.85)),
                 ),
               ],
             ),

+ 109 - 92
lib/screens/live_analysis_screen.dart

@@ -1,17 +1,13 @@
 import 'dart:io';
-import 'dart:math';
-import 'dart:ui';
 import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:camera/camera.dart';
 import 'package:permission_handler/permission_handler.dart';
-import 'package:path_provider/path_provider.dart';
-import 'package:path/path.dart' as p;
 import '../services/tflite_service.dart';
-import '../services/database_helper.dart';
-import '../models/palm_record.dart';
 import '../widgets/palm_bounding_box.dart';
 import '../constants/mpob_colors.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 
 enum DetectionState { searching, locking, capturing, cooldown }
 
@@ -25,7 +21,6 @@ class LiveAnalysisScreen extends StatefulWidget {
 class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
   CameraController? _controller;
   final TfliteService _tfliteService = TfliteService();
-  final DatabaseHelper _dbHelper = DatabaseHelper();
 
   bool _isInitialized = false;
   bool _isProcessing = false;
@@ -45,8 +40,14 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
   double _lockProgress = 0.0;
   bool _showFlash = false;
 
-  // One batch groups every capture made during this screen visit.
-  late final String _batchId = 'batch_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1 << 32)}';
+  // Raw CameraImage stream frames arrive in the sensor's native (landscape)
+  // orientation, never pre-rotated for display — unlike CameraPreview's own
+  // texture, which is rotated internally to appear upright. Detection boxes
+  // decoded from the stream must be rotated by this same amount before
+  // rendering, or they land in the wrong place/orientation relative to what's
+  // shown on screen. Defaults to 90 (the standard rear-camera value on
+  // Android) until the actual camera's sensorOrientation is known.
+  int _sensorOrientation = 90;
 
   @override
   void initState() {
@@ -61,6 +62,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
     final cameras = await availableCameras();
     if (cameras.isEmpty) return;
 
+    _sensorOrientation = cameras[0].sensorOrientation;
+
     _controller = CameraController(
       cameras[0],
       ResolutionPreset.medium, // Upgraded resolution for YOLO26 performance
@@ -80,7 +83,7 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
         });
       }
     } catch (e) {
-      print("Camera init error: $e");
+      debugPrint("Camera init error: $e");
     }
   }
 
@@ -96,8 +99,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
   Future<void> _processStreamFrame(CameraImage image) async {
     setState(() => _isProcessing = true);
     try {
-      final detections = await _tfliteService.runInferenceOnStream(image);
-      
+      final detections = _rotateDetections(await _tfliteService.runInferenceOnStream(image));
+
       bool currentFrameHasFruit = false;
       if (detections.isNotEmpty) {
         currentFrameHasFruit = detections.any((d) => d.confidence > _lockThreshold);
@@ -127,7 +130,7 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
       // Removed the old strict cancel logic.
       // _startLockTimer now safely handles momentum drain.
     } catch (e) {
-      print("Stream processing error: $e");
+      debugPrint("Stream processing error: $e");
     } finally {
       if (mounted) {
         setState(() => _isProcessing = false);
@@ -135,6 +138,37 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
     }
   }
 
+  /// Rotates each detection's normalized box from raw sensor-space into
+  /// display-space, matching the rotation CameraPreview's own texture
+  /// undergoes internally. Confidence/class are untouched.
+  List<DetectionResult> _rotateDetections(List<DetectionResult> detections) {
+    final quarterTurns = (_sensorOrientation ~/ 90) % 4;
+    if (quarterTurns == 0) return detections;
+    return detections
+        .map((d) => DetectionResult(
+              className: d.className,
+              classIndex: d.classIndex,
+              confidence: d.confidence,
+              normalizedBox: _rotateNormalizedRect(d.normalizedBox, quarterTurns),
+            ))
+        .toList();
+  }
+
+  /// Rotates an axis-aligned rect in normalized [0,1]x[0,1] space by
+  /// [quarterTurns] * 90° clockwise.
+  Rect _rotateNormalizedRect(Rect r, int quarterTurns) {
+    switch (quarterTurns) {
+      case 1: // 90° clockwise
+        return Rect.fromLTRB(1 - r.bottom, r.left, 1 - r.top, r.right);
+      case 2: // 180°
+        return Rect.fromLTRB(1 - r.right, 1 - r.bottom, 1 - r.left, 1 - r.top);
+      case 3: // 270° clockwise (90° counter-clockwise)
+        return Rect.fromLTRB(r.top, 1 - r.right, r.bottom, 1 - r.left);
+      default:
+        return r;
+    }
+  }
+
   void _startLockTimer() {
     _lockTimer?.cancel();
     const duration = Duration(milliseconds: 100);
@@ -174,11 +208,6 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
     });
   }
 
-  void _cancelLockTimer() {
-    _lockTimer?.cancel();
-    _lockTimer = null;
-  }
-
   Future<void> _triggerCapture() async {
     setState(() {
       _state = DetectionState.capturing;
@@ -194,12 +223,15 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
     await _captureAndAnalyze();
   }
 
+  /// Live Inference is a quick verification tool, not a capture mode — the
+  /// result is shown once and then discarded. Nothing is written to history;
+  /// use Snap & Analyze if you want the result kept.
   Future<void> _captureAndAnalyze() async {
     if (_controller == null || !_controller!.value.isInitialized) return;
 
     // 1. Stop stream to avoid resource conflict
     await _controller!.stopImageStream();
-    
+
     if (!mounted) return;
 
     try {
@@ -207,57 +239,27 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
       final XFile photo = await _controller!.takePicture();
 
       // 3. Run final inference on high-res
-      final stopwatch = Stopwatch()..start();
       final detections = await _tfliteService.runInference(photo.path);
-      stopwatch.stop();
 
-      if (detections.isNotEmpty) {
-        // 4. Archive
-        final appDocDir = await getApplicationDocumentsDirectory();
-        final fileName = p.basename(photo.path);
-        final persistentPath = p.join(appDocDir.path, 'palm_live_${DateTime.now().millisecondsSinceEpoch}_$fileName');
-        await File(photo.path).copy(persistentPath);
+      // 4. Discard the temp capture — nothing persists past this quick check.
+      unawaited(File(photo.path).delete().catchError((_) => File(photo.path)));
 
+      if (detections.isNotEmpty) {
         final best = detections.first;
-        final record = PalmRecord(
-          imagePath: persistentPath,
-          ripenessClass: best.className,
-          confidence: best.confidence,
-          timestamp: DateTime.now(),
-          x1: best.normalizedBox.left,
-          y1: best.normalizedBox.top,
-          x2: best.normalizedBox.right,
-          y2: best.normalizedBox.bottom,
-          detections: detections.map((d) => {
-            'className': d.className,
-            'classIndex': d.classIndex,
-            'confidence': d.confidence,
-            'x1': d.normalizedBox.left,
-            'y1': d.normalizedBox.top,
-            'x2': d.normalizedBox.right,
-            'y2': d.normalizedBox.bottom,
-          }).toList(),
-          batchId: _batchId,
-          inferenceMs: stopwatch.elapsedMilliseconds,
-        );
-
-        await _dbHelper.insertRecord(record);
-
-        // 5. Show result and resume camera
         if (mounted) {
-          await _showResultSheet(record);
+          await _showResultSheet(best.className, best.confidence);
           _startCooldown();
         }
       } else {
          if (mounted) {
           ScaffoldMessenger.of(context).showSnackBar(
-            const SnackBar(content: Text("No palm bunches detected in final snap."))
+            SnackBar(content: Text(AppStrings.t('live_no_detections_snackbar')))
           );
           _startCooldown();
         }
       }
     } catch (e) {
-      print("Capture error: $e");
+      debugPrint("Capture error: $e");
       if (mounted) _startCooldown();
     }
   }
@@ -282,8 +284,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
     });
   }
 
-  Future<void> _showResultSheet(PalmRecord record) async {
-    final Color statusColor = getStatusColor(record.ripenessClass);
+  Future<void> _showResultSheet(String ripenessClass, double confidence) async {
+    final Color statusColor = getStatusColor(ripenessClass);
     final colorScheme = Theme.of(context).colorScheme;
 
     await showModalBottomSheet(
@@ -303,21 +305,24 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
           children: [
             Icon(Icons.check_circle, color: statusColor, size: 64),
             const SizedBox(height: 16),
-            Text(record.ripenessClass, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
-            Text("Confidence: ${(record.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6))),
+            Text(AppStrings.className(ripenessClass), style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+            Text(
+              AppStrings.tp('confidence_label', {'percent': (confidence * 100).toStringAsFixed(1)}),
+              style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6)),
+            ),
             const SizedBox(height: 24),
-            if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
+            if (ripenessClass == 'Empty_Bunch' || ripenessClass == 'Abnormal')
               Container(
                 padding: const EdgeInsets.all(12),
                 decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(8)),
-                child: const Text("HEALTH ALERT: Abnormal detected!", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
+                child: Text(AppStrings.t('health_alert_abnormal'), style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
               ),
             const SizedBox(height: 24),
             SizedBox(
               width: double.infinity,
               child: ElevatedButton(
                 onPressed: () => Navigator.pop(context),
-                child: const Text("Done"),
+                child: Text(AppStrings.t('done_button')),
               ),
             ),
           ],
@@ -328,6 +333,13 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     if (!_isInitialized || _controller == null) {
       return const Scaffold(body: Center(child: CircularProgressIndicator()));
     }
@@ -338,30 +350,35 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
       backgroundColor: Colors.black,
       body: Stack(
         children: [
-          // Camera Preview
+          // Camera Preview + Bounding Box Overlays. The overlay is passed as
+          // CameraPreview's own `child` slot (not a separate sibling) so it's
+          // measured against the exact same orientation-aware AspectRatio box
+          // CameraPreview computes internally for the texture itself —
+          // otherwise the overlay measures the full screen while the preview
+          // is letterboxed/pillarboxed to the camera's real aspect ratio,
+          // causing misaligned/stretched boxes.
           Center(
-            child: CameraPreview(_controller!),
-          ),
-          
-          // Bounding Box Overlays
-          if (_detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown)
-            Positioned.fill(
-              child: LayoutBuilder(
-                builder: (context, constraints) {
-                  return Stack(
-                    children: _detections!
-                        .map((d) => PalmBoundingBox(
-                              normalizedRect: d.normalizedBox,
-                              label: d.className,
-                              confidence: d.confidence,
-                              constraints: constraints,
-                              isLocked: (_state == DetectionState.locking || _state == DetectionState.capturing) && d.confidence > _lockThreshold,
-                            ))
-                        .toList(),
-                  );
-                },
-              ),
+            child: CameraPreview(
+              _controller!,
+              child: _detections != null && _state != DetectionState.capturing && _state != DetectionState.cooldown
+                  ? LayoutBuilder(
+                      builder: (context, constraints) {
+                        return Stack(
+                          children: _detections!
+                              .map((d) => PalmBoundingBox(
+                                    normalizedRect: d.normalizedBox,
+                                    label: d.className,
+                                    confidence: d.confidence,
+                                    constraints: constraints,
+                                    isLocked: (_state == DetectionState.locking || _state == DetectionState.capturing) && d.confidence > _lockThreshold,
+                                  ))
+                              .toList(),
+                        );
+                      },
+                    )
+                  : null,
             ),
+          ),
 
           // Top Info Bar
           Positioned(
@@ -384,8 +401,8 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
                   ),
                   const SizedBox(width: 8),
                   Text(
-                    _state == DetectionState.cooldown ? "COOLDOWN" : 
-                    isLockedVisual ? "LOCKING" : "SEARCHING...",
+                    _state == DetectionState.cooldown ? AppStrings.t('live_state_cooldown') :
+                    isLockedVisual ? AppStrings.t('live_state_locking') : AppStrings.t('live_state_searching'),
                     style: TextStyle(
                       color: _state == DetectionState.cooldown ? Colors.blue : 
                              isLockedVisual ? Colors.green : Colors.yellow,
@@ -448,10 +465,10 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
              Positioned.fill(
               child: Container(
                 color: Colors.black45,
-                child: const Center(
+                child: Center(
                   child: Text(
-                    "Resuming scan...",
-                    style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
+                    AppStrings.t('live_resuming_scan'),
+                    style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
                   ),
                 ),
               ),
@@ -459,14 +476,14 @@ class _LiveAnalysisScreenState extends State<LiveAnalysisScreen> {
 
           // Bottom Hint
           if (_state == DetectionState.searching)
-            const Positioned(
+            Positioned(
               bottom: 40,
               left: 0,
               right: 0,
               child: Center(
                 child: Text(
-                  "Hold steady to lock target",
-                  style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
+                  AppStrings.t('live_hold_steady_hint'),
+                  style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
                 ),
               ),
             ),

+ 109 - 0
lib/screens/manual_screen.dart

@@ -0,0 +1,109 @@
+import 'package:flutter/material.dart';
+import '../constants/mpob_colors.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
+
+class ManualScreen extends StatelessWidget {
+  const ManualScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) {
+        final colorScheme = Theme.of(context).colorScheme;
+        return Scaffold(
+          appBar: AppBar(title: Text(AppStrings.t('manual_title'))),
+          body: ListView(
+            padding: const EdgeInsets.symmetric(vertical: 8),
+            children: [
+              _buildSection(
+                icon: Icons.photo_library,
+                title: AppStrings.t('manual_gallery_title'),
+                body: AppStrings.t('manual_gallery_body'),
+              ),
+              _buildSection(
+                icon: Icons.camera_alt,
+                title: AppStrings.t('manual_snap_title'),
+                body: AppStrings.t('manual_snap_body'),
+              ),
+              _buildSection(
+                icon: Icons.camera,
+                title: AppStrings.t('manual_live_title'),
+                body: AppStrings.t('manual_live_body'),
+              ),
+              _buildSection(
+                icon: Icons.history,
+                title: AppStrings.t('manual_history_title'),
+                body: AppStrings.t('manual_history_body'),
+              ),
+              _buildSection(
+                icon: Icons.edit_outlined,
+                title: AppStrings.t('manual_correction_title'),
+                body: AppStrings.t('manual_correction_body'),
+              ),
+              _buildSection(
+                icon: Icons.insights,
+                title: AppStrings.t('manual_batch_title'),
+                body: AppStrings.t('manual_batch_body'),
+              ),
+              ExpansionTile(
+                leading: const Icon(Icons.palette_outlined),
+                title: Text(AppStrings.t('manual_classes_title')),
+                children: mpobClasses.map((key) {
+                  return Padding(
+                    padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+                    child: Row(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Container(
+                          width: 14,
+                          height: 14,
+                          margin: const EdgeInsets.only(top: 3),
+                          decoration: BoxDecoration(
+                            color: getMPOBColor(key),
+                            shape: BoxShape.circle,
+                          ),
+                        ),
+                        const SizedBox(width: 10),
+                        Expanded(
+                          child: Column(
+                            crossAxisAlignment: CrossAxisAlignment.start,
+                            children: [
+                              Text(
+                                AppStrings.className(key),
+                                style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
+                              ),
+                              const SizedBox(height: 2),
+                              Text(
+                                AppStrings.t('manual_class_desc_$key'),
+                                style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.75)),
+                              ),
+                            ],
+                          ),
+                        ),
+                      ],
+                    ),
+                  );
+                }).toList(),
+              ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+
+  Widget _buildSection({required IconData icon, required String title, required String body}) {
+    return ExpansionTile(
+      leading: Icon(icon),
+      title: Text(title),
+      children: [
+        Padding(
+          padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+          child: Text(body),
+        ),
+      ],
+    );
+  }
+}

+ 44 - 25
lib/screens/record_detail_screen.dart

@@ -1,5 +1,4 @@
 import 'dart:io';
-import 'dart:ui';
 import 'package:flutter/material.dart';
 import 'package:path/path.dart' as p;
 import '../models/palm_record.dart';
@@ -9,6 +8,8 @@ import '../constants/mpob_colors.dart';
 import '../theme/app_theme.dart';
 import '../widgets/palm_bounding_box.dart';
 import '../widgets/class_picker_sheet.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 import 'detection_editor_screen.dart';
 
 /// Detection data parsed from the JSON-serialized `detections` list stored
@@ -102,12 +103,21 @@ class _RecordDetailScreenState extends State<RecordDetailScreen> {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     final isFirst = _index == 0;
     final isLast = _index == widget.frames.length - 1;
 
     return Scaffold(
       appBar: AppBar(
-        title: Text(widget.frames.length > 1 ? "Frame ${_index + 1} of ${widget.frames.length}" : "Record Details"),
+        title: Text(widget.frames.length > 1
+            ? AppStrings.tp('frame_of_title', {'current': '${_index + 1}', 'total': '${widget.frames.length}'})
+            : AppStrings.t('record_details_title')),
         actions: widget.frames.length > 1
             ? [
                 IconButton(
@@ -269,12 +279,19 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
       _viewMode = _ViewMode.ai;
     });
     ScaffoldMessenger.of(context).showSnackBar(
-      const SnackBar(content: Text("Correction reset.")),
+      SnackBar(content: Text(AppStrings.t('correction_reset_snackbar'))),
     );
   }
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildBody(context),
+    );
+  }
+
+  Widget _buildBody(BuildContext context) {
     final colorScheme = Theme.of(context).colorScheme;
     final accents = AppTheme.chartAccents;
 
@@ -285,7 +302,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
     final record = widget.record;
     final fileName = p.basename(record.imagePath);
     final activeDetections = _viewMode == _ViewMode.ai ? _aiDetections : _correctionDraft;
-    final primaryGrade = activeDetections.isEmpty ? record.ripenessClass : activeDetections.first.className;
+    final primaryGradeKey = activeDetections.isEmpty ? record.ripenessClass : activeDetections.first.className;
     final tally = <String, int>{};
     for (final d in activeDetections) {
       tally[d.className] = (tally[d.className] ?? 0) + 1;
@@ -298,9 +315,9 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
         children: [
           if (_hasCorrection) ...[
             SegmentedButton<_ViewMode>(
-              segments: const [
-                ButtonSegment(value: _ViewMode.ai, label: Text("AI Result"), icon: Icon(Icons.smart_toy_outlined)),
-                ButtonSegment(value: _ViewMode.corrected, label: Text("Your Correction"), icon: Icon(Icons.edit_outlined)),
+              segments: [
+                ButtonSegment(value: _ViewMode.ai, label: Text(AppStrings.t('ai_result_label')), icon: const Icon(Icons.smart_toy_outlined)),
+                ButtonSegment(value: _ViewMode.corrected, label: Text(AppStrings.t('your_correction_label')), icon: const Icon(Icons.edit_outlined)),
               ],
               selected: {_viewMode},
               showSelectedIcon: false,
@@ -317,16 +334,18 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
               Expanded(
                 child: Text(
                   _viewMode == _ViewMode.ai
-                      ? "Tap a bunch on the photo to correct its grade."
-                      : (_isSaving ? "Saving..." : "Last corrected: ${_lastCorrectedAt.toString().split('.')[0]}"),
-                  style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withOpacity(0.6)),
+                      ? AppStrings.t('tap_to_correct_hint')
+                      : (_isSaving
+                          ? AppStrings.t('saving_label')
+                          : AppStrings.tp('last_corrected_label', {'timestamp': _lastCorrectedAt.toString().split('.')[0]})),
+                  style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withValues(alpha: 0.6)),
                 ),
               ),
               if (_viewMode == _ViewMode.corrected && _hasCorrection)
                 TextButton.icon(
                   onPressed: _isSaving ? null : _resetCorrection,
                   icon: const Icon(Icons.restart_alt, size: 18),
-                  label: const Text("Reset"),
+                  label: Text(AppStrings.t('reset_button')),
                 ),
             ],
           ),
@@ -336,7 +355,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
             child: OutlinedButton.icon(
               onPressed: _isSaving ? null : _openDetectionEditor,
               icon: const Icon(Icons.add_box_outlined, size: 18),
-              label: const Text("Add Missed Bunch"),
+              label: Text(AppStrings.t('add_missed_bunch_button')),
             ),
           ),
           const SizedBox(height: 16),
@@ -395,21 +414,21 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
                       ),
                     )
                   : Center(
-                      child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withOpacity(0.4)),
+                      child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.4)),
                     ),
             ),
           ),
 
           const SizedBox(height: 24),
-          _buildDetailItem("File Name", fileName, accents[4], colorScheme),
-          _buildDetailItem("Primary Grade", primaryGrade, getStatusColor(primaryGrade), colorScheme),
-          _buildDetailItem("Total Detections", activeDetections.length.toString(), colorScheme.onSurface, colorScheme),
-          _buildDetailItem("Inference Time", record.inferenceMs == null ? "N/A" : "${record.inferenceMs} ms", accents[3], colorScheme),
-          _buildDetailItem("Timestamp", record.timestamp.toString().split('.')[0], colorScheme.onSurface, colorScheme),
+          _buildDetailItem(AppStrings.t('label_file_name'), fileName, accents[4], colorScheme),
+          _buildDetailItem(AppStrings.t('label_primary_grade'), AppStrings.className(primaryGradeKey), getStatusColor(primaryGradeKey), colorScheme),
+          _buildDetailItem(AppStrings.t('kpi_total_detections'), activeDetections.length.toString(), colorScheme.onSurface, colorScheme),
+          _buildDetailItem(AppStrings.t('label_inference_time'), record.inferenceMs == null ? AppStrings.t('na_label') : "${record.inferenceMs} ms", accents[3], colorScheme),
+          _buildDetailItem(AppStrings.t('label_timestamp'), record.timestamp.toString().split('.')[0], colorScheme.onSurface, colorScheme),
 
           if (tally.isNotEmpty) ...[
             const Divider(height: 32),
-            Text("Frame Summary", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+            Text(AppStrings.t('frame_summary_title'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
             const SizedBox(height: 12),
             Wrap(
               spacing: 16,
@@ -420,7 +439,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
                   children: [
                     Container(width: 12, height: 12, decoration: BoxDecoration(color: getMPOBColor(e.key), shape: BoxShape.circle)),
                     const SizedBox(width: 6),
-                    Text("${e.key}: ${e.value}", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)),
+                    Text("${AppStrings.className(e.key)}: ${e.value}", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)),
                   ],
                 );
               }).toList(),
@@ -429,7 +448,7 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
 
           const Divider(height: 32),
           Text(
-            _viewMode == _ViewMode.ai ? "Analytical Breakdown" : "Corrected Breakdown (tap a bunch above to edit or remove)",
+            _viewMode == _ViewMode.ai ? AppStrings.t('analytical_breakdown_title') : AppStrings.t('corrected_breakdown_title'),
             style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
           ),
           const SizedBox(height: 12),
@@ -450,18 +469,18 @@ class _RecordDetailBodyState extends State<_RecordDetailBody> {
                       ),
                     ),
                     const SizedBox(width: 8),
-                    Text(d.className, style: TextStyle(fontWeight: FontWeight.w500, color: colorScheme.onSurface)),
+                    Text(AppStrings.className(d.className), style: TextStyle(fontWeight: FontWeight.w500, color: colorScheme.onSurface)),
                   ],
                 ),
-                Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6))),
+                Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6))),
               ],
             ),
           )),
 
           const SizedBox(height: 24),
           Text(
-            "Industrial Summary: This image has been verified for harvest quality. All detected bunches are categorized according to ripeness standards.",
-            style: TextStyle(fontStyle: FontStyle.italic, color: colorScheme.onSurface.withOpacity(0.6)),
+            AppStrings.t('industrial_summary_text'),
+            style: TextStyle(fontStyle: FontStyle.italic, color: colorScheme.onSurface.withValues(alpha: 0.6)),
           ),
           const SizedBox(height: 24),
         ],

+ 43 - 27
lib/screens/static_capture_screen.dart

@@ -10,6 +10,8 @@ import '../services/tflite_service.dart';
 import '../services/database_helper.dart';
 import '../models/palm_record.dart';
 import '../constants/mpob_colors.dart';
+import '../services/app_strings.dart';
+import '../theme/locale_controller.dart';
 
 class StaticCaptureScreen extends StatefulWidget {
   const StaticCaptureScreen({super.key});
@@ -42,7 +44,7 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
     final status = await Permission.camera.request();
     if (status.isDenied) {
       if (mounted) {
-        setState(() => _errorMessage = "Camera permission denied.");
+        setState(() => _errorMessage = AppStrings.t('camera_permission_denied'));
       }
       return;
     }
@@ -50,7 +52,7 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
     final cameras = await availableCameras();
     if (cameras.isEmpty) {
       if (mounted) {
-        setState(() => _errorMessage = "No cameras found.");
+        setState(() => _errorMessage = AppStrings.t('no_cameras_found'));
       }
       return;
     }
@@ -69,7 +71,7 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
       }
     } catch (e) {
       if (mounted) {
-        setState(() => _errorMessage = "Camera init error: $e");
+        setState(() => _errorMessage = AppStrings.tp('camera_init_error', {'error': '$e'}));
       }
     }
   }
@@ -137,12 +139,12 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
         }
       } else {
         if (mounted) {
-          setState(() => _errorMessage = "No palm bunches detected.");
+          setState(() => _errorMessage = AppStrings.t('static_no_detections'));
         }
       }
     } catch (e) {
       if (mounted) {
-        setState(() => _errorMessage = "Error during capture/analysis: $e");
+        setState(() => _errorMessage = AppStrings.tp('static_capture_error', {'error': '$e'}));
       }
     } finally {
       if (mounted) {
@@ -170,16 +172,23 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildScaffold(context),
+    );
+  }
+
+  Widget _buildScaffold(BuildContext context) {
     if (_errorMessage != null) {
       return Scaffold(
-        appBar: AppBar(title: const Text('Capture & Analyze')),
+        appBar: AppBar(title: Text(AppStrings.t('static_appbar_title'))),
         body: Center(
           child: Column(
             mainAxisAlignment: MainAxisAlignment.center,
             children: [
               Text(_errorMessage!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
               const SizedBox(height: 16),
-              ElevatedButton(onPressed: _initializeCamera, child: const Text("Retry")),
+              ElevatedButton(onPressed: _initializeCamera, child: Text(AppStrings.t('retry_button'))),
             ],
           ),
         ),
@@ -303,7 +312,7 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
             padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
             color: color,
             child: Text(
-              "${detection.className} ${(detection.confidence * 100).toStringAsFixed(0)}%",
+              "${AppStrings.className(detection.className)} ${(detection.confidence * 100).toStringAsFixed(0)}%",
               style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
             ),
           ),
@@ -317,16 +326,16 @@ class _StaticCaptureScreenState extends State<StaticCaptureScreen> {
       child: BackdropFilter(
         filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
         child: Container(
-          color: Colors.black.withOpacity(0.5),
-          child: const Center(
+          color: Colors.black.withValues(alpha: 0.5),
+          child: Center(
             child: Column(
               mainAxisSize: MainAxisSize.min,
               children: [
-                CircularProgressIndicator(color: Colors.white, strokeWidth: 6),
-                SizedBox(height: 24),
+                const CircularProgressIndicator(color: Colors.white, strokeWidth: 6),
+                const SizedBox(height: 24),
                 Text(
-                  "AI is Analyzing...",
-                  style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
+                  AppStrings.t('ai_analyzing'),
+                  style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
                 ),
               ],
             ),
@@ -350,6 +359,13 @@ class _ResultSheet extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    return ValueListenableBuilder<String>(
+      valueListenable: LocaleController.localeCode,
+      builder: (context, _, _) => _buildSheet(context),
+    );
+  }
+
+  Widget _buildSheet(BuildContext context) {
     final colorScheme = Theme.of(context).colorScheme;
     final Color statusColor = getStatusColor(record.ripenessClass);
 
@@ -367,25 +383,25 @@ class _ResultSheet extends StatelessWidget {
           Container(
             width: 40,
             height: 4,
-            decoration: BoxDecoration(color: colorScheme.onSurface.withOpacity(0.15), borderRadius: BorderRadius.circular(2)),
+            decoration: BoxDecoration(color: colorScheme.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2)),
           ),
           const SizedBox(height: 24),
           Row(
             children: [
               Icon(Icons.analytics, color: statusColor, size: 32),
               const SizedBox(width: 12),
-              Text("Analysis Result", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+              Text(AppStrings.t('result_sheet_title'), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
             ],
           ),
           const SizedBox(height: 24),
-          _buildInfoRow("Ripeness", record.ripenessClass, statusColor, colorScheme),
-          _buildInfoRow("Confidence", "${(record.confidence * 100).toStringAsFixed(1)}%", colorScheme.onSurface, colorScheme),
-          
+          _buildInfoRow(AppStrings.t('label_ripeness'), AppStrings.className(record.ripenessClass), statusColor, colorScheme),
+          _buildInfoRow(AppStrings.t('label_confidence'), "${(record.confidence * 100).toStringAsFixed(1)}%", colorScheme.onSurface, colorScheme),
+
           if (record.ripenessClass == 'Empty_Bunch' || record.ripenessClass == 'Abnormal')
-            _buildAlertCard("HEALTH ALERT", "Abnormal features detected. Check palm health.", Colors.red),
-          
+            _buildAlertCard(AppStrings.t('health_alert_title'), AppStrings.t('health_alert_message'), Colors.red),
+
           if (isWarning)
-            _buildAlertCard("YIELD WARNING", "Potential Yield Loss due to premature harvest.", Colors.orange),
+            _buildAlertCard(AppStrings.t('yield_warning_title'), AppStrings.t('yield_warning_message'), Colors.orange),
 
           const SizedBox(height: 32),
           SizedBox(
@@ -398,7 +414,7 @@ class _ResultSheet extends StatelessWidget {
                 padding: const EdgeInsets.symmetric(vertical: 16),
                 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
               ),
-              child: const Text("Acknowledge", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
+              child: Text(AppStrings.t('acknowledge_button'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
             ),
           ),
           const SizedBox(height: 12),
@@ -413,7 +429,7 @@ class _ResultSheet extends StatelessWidget {
       child: Row(
         mainAxisAlignment: MainAxisAlignment.spaceBetween,
         children: [
-          Text(label, style: TextStyle(fontSize: 16, color: colorScheme.onSurface.withOpacity(0.6))),
+          Text(label, style: TextStyle(fontSize: 16, color: colorScheme.onSurface.withValues(alpha: 0.6))),
           Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
         ],
       ),
@@ -425,9 +441,9 @@ class _ResultSheet extends StatelessWidget {
       margin: const EdgeInsets.only(top: 16),
       padding: const EdgeInsets.all(16),
       decoration: BoxDecoration(
-        color: color.withOpacity(0.1),
+        color: color.withValues(alpha: 0.1),
         borderRadius: BorderRadius.circular(12),
-        border: Border.all(color: color.withOpacity(0.3)),
+        border: Border.all(color: color.withValues(alpha: 0.3)),
       ),
       child: Row(
         children: [
@@ -438,7 +454,7 @@ class _ResultSheet extends StatelessWidget {
               crossAxisAlignment: CrossAxisAlignment.start,
               children: [
                 Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14)),
-                Text(message, style: TextStyle(color: color.withOpacity(0.8), fontSize: 12)),
+                Text(message, style: TextStyle(color: color.withValues(alpha: 0.8), fontSize: 12)),
               ],
             ),
           ),

+ 44 - 0
lib/services/app_strings.dart

@@ -0,0 +1,44 @@
+import 'dart:convert';
+import 'package:flutter/services.dart' show rootBundle;
+import '../theme/locale_controller.dart';
+
+/// Hand-rolled translation dictionary loaded from flat JSON files in
+/// `assets/lang/`, one per language — freely hand-editable, no codegen.
+/// All dictionaries are preloaded at startup since they're tiny, so [t]
+/// is a pure synchronous lookup with no async gap on language switch.
+class AppStrings {
+  AppStrings._();
+
+  static const supportedLocales = ['en', 'ms', 'zh'];
+  static final Map<String, Map<String, String>> _dictionaries = {};
+
+  static Future<void> preloadAll() async {
+    for (final code in supportedLocales) {
+      final raw = await rootBundle.loadString('assets/lang/$code.json');
+      _dictionaries[code] = Map<String, String>.from(json.decode(raw));
+    }
+  }
+
+  /// Looks up [key] in the active locale, falling back to English, then
+  /// to the raw key itself — a missing translation is loud, not silent.
+  static String t(String key) {
+    final code = LocaleController.localeCode.value;
+    return _dictionaries[code]?[key] ?? _dictionaries['en']?[key] ?? key;
+  }
+
+  /// Like [t], but replaces `{placeholder}` tokens with [params] — for
+  /// strings with dynamic content (counts, timestamps, error messages).
+  static String tp(String key, Map<String, String> params) {
+    var result = t(key);
+    for (final entry in params.entries) {
+      result = result.replaceAll('{${entry.key}}', entry.value);
+    }
+    return result;
+  }
+
+  /// Display-only translation for an MPOB class name (e.g. "Ripe").
+  /// The underlying data key (used for DB storage, color lookups, and
+  /// tally maps) is never touched — this only affects what's shown on
+  /// screen.
+  static String className(String englishClassName) => t('class_$englishClassName');
+}

+ 5 - 9
lib/services/tflite_service.dart

@@ -1,6 +1,4 @@
 import 'dart:io';
-import 'dart:math';
-import 'dart:ui';
 import 'dart:typed_data';
 import 'dart:isolate';
 import 'dart:async';
@@ -38,7 +36,6 @@ class TfliteService {
   SendPort? _sendPort;
   ReceivePort? _receivePort;
 
-  List<String> _labels = [];
   final ImagePicker _picker = ImagePicker();
   bool _isInitialized = false;
   bool _isIsolateBusy = false;
@@ -49,7 +46,6 @@ class TfliteService {
   Future<void> initModel() async {
     try {
       final labelData = await rootBundle.loadString('assets/$_labelsAsset');
-      _labels = labelData.split('\n').where((l) => l.trim().isNotEmpty).map((l) => l.trim()).toList();
 
       final modelData = await rootBundle.load('assets/$_modelAsset');
       final modelBytes = modelData.buffer.asUint8List();
@@ -89,9 +85,9 @@ class TfliteService {
       await initCompleter.future;
 
       _isInitialized = true;
-      print('TfliteService: Model loaded via persistent isolate.');
+      debugPrint('TfliteService: Model loaded via persistent isolate.');
     } catch (e) {
-      print('TfliteService init error: $e');
+      debugPrint('TfliteService init error: $e');
       rethrow;
     }
   }
@@ -245,7 +241,7 @@ class TfliteService {
         fullHeight: height
       );
     } catch (e) {
-      print('Isolate static inference error: $e');
+      debugPrint('Isolate static inference error: $e');
       return <DetectionResult>[];
     }
   }
@@ -280,7 +276,7 @@ class TfliteService {
           offsetY: offsetY,
         );
       } else {
-        print("TfliteService: Unsupported format: $format. Ensure platform correctly requests YUV420 or BGRA.");
+        debugPrint("TfliteService: Unsupported format: $format. Ensure platform correctly requests YUV420 or BGRA.");
         return <DetectionResult>[];
       }
 
@@ -314,7 +310,7 @@ class TfliteService {
         fullHeight: height
       );
     } catch (e) {
-      print('Isolate stream inference error: $e');
+      debugPrint('Isolate stream inference error: $e');
       return <DetectionResult>[];
     }
   }

+ 23 - 0
lib/theme/locale_controller.dart

@@ -0,0 +1,23 @@
+import 'package:flutter/material.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// App-wide language, persisted across launches. Defaults to English
+/// until a user preference is loaded/set — no device-locale auto-detect.
+class LocaleController {
+  LocaleController._();
+
+  static const _prefsKey = 'app_locale';
+
+  static final ValueNotifier<String> localeCode = ValueNotifier('en');
+
+  static Future<void> load() async {
+    final prefs = await SharedPreferences.getInstance();
+    localeCode.value = prefs.getString(_prefsKey) ?? 'en';
+  }
+
+  static Future<void> setLocale(String code) async {
+    localeCode.value = code;
+    final prefs = await SharedPreferences.getInstance();
+    await prefs.setString(_prefsKey, code);
+  }
+}

+ 4 - 3
lib/widgets/class_picker_sheet.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import '../constants/mpob_colors.dart';
+import '../services/app_strings.dart';
 
 /// Result of [showClassPickerSheet]: either a picked [className], or
 /// [remove] set when the harvester chose to delete the detection entirely.
@@ -30,13 +31,13 @@ Future<ClassPickResult?> showClassPickerSheet(
               padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
               child: Align(
                 alignment: Alignment.centerLeft,
-                child: Text("Correct Grade", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
+                child: Text(AppStrings.t('correct_grade_title'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
               ),
             ),
             ...mpobClasses.map((className) {
               return ListTile(
                 leading: Container(width: 14, height: 14, decoration: BoxDecoration(color: getMPOBColor(className), shape: BoxShape.circle)),
-                title: Text(className),
+                title: Text(AppStrings.className(className)),
                 trailing: currentClass == className ? const Icon(Icons.check) : null,
                 onTap: () => Navigator.pop(sheetContext, ClassPickResult(className: className)),
               );
@@ -45,7 +46,7 @@ Future<ClassPickResult?> showClassPickerSheet(
               const Divider(height: 1),
               ListTile(
                 leading: Icon(Icons.delete_outline, color: colorScheme.error),
-                title: Text("Remove this detection", style: TextStyle(color: colorScheme.error)),
+                title: Text(AppStrings.t('remove_detection_button'), style: TextStyle(color: colorScheme.error)),
                 onTap: () => Navigator.pop(sheetContext, const ClassPickResult(remove: true)),
               ),
             ],

+ 2 - 1
lib/widgets/palm_bounding_box.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import '../constants/mpob_colors.dart';
+import '../services/app_strings.dart';
 
 /// A shared widget for displaying MPOB-standard bounding boxes.
 class PalmBoundingBox extends StatelessWidget {
@@ -50,7 +51,7 @@ class PalmBoundingBox extends StatelessWidget {
               padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
               color: color,
               child: Text(
-                "$label ${(confidence * 100).toStringAsFixed(0)}%${isManual ? ' • Added' : ''}",
+                "${AppStrings.className(label)} ${(confidence * 100).toStringAsFixed(0)}%${isManual ? AppStrings.t('added_suffix') : ''}",
                 style: const TextStyle(
                   color: Colors.white,
                   fontSize: 10,

+ 1 - 0
pubspec.yaml

@@ -80,6 +80,7 @@ flutter:
     - assets/labels.txt
     - assets/icon_light.png
     - assets/icon_dark.png
+    - assets/lang/
 
   # An image asset can refer to one or more resolution-specific "variants", see
   # https://flutter.dev/to/resolution-aware-images