001package com.studentgui.apppages; 002 003import java.awt.BorderLayout; 004import java.awt.Font; 005import java.awt.GridBagConstraints; 006import java.awt.GridBagLayout; 007import java.awt.Insets; 008import java.awt.event.ActionEvent; 009import java.awt.event.KeyEvent; 010import java.sql.SQLException; 011import java.time.LocalDate; 012import java.util.LinkedHashMap; 013import java.util.Map; 014 015import javax.swing.JButton; 016import javax.swing.JLabel; 017import javax.swing.JPanel; 018import javax.swing.JScrollPane; 019import javax.swing.SwingUtilities; 020 021import org.slf4j.Logger; 022import org.slf4j.LoggerFactory; 023 024import com.studentgui.uicomp.PhaseScoreField; 025 026/** 027 * HIMS BrailleSense productivity device proficiency assessment page. 028 * 029 * <p>Evaluates student competency with the HIMS BrailleSense family of refreshable braille 030 * notetakers (BrailleSense Polaris, BrailleSense 6, etc.) across 52 skills organized into 031 * 12 functional domains. The BrailleSense assessment structure mirrors {@link BrailleNote} 032 * to allow cross-device skill comparison.</p> 033 * 034 * <p><b>Device Family Context:</b> The BrailleSense is a portable braille notetaker with 035 * refreshable braille display, perkins-style keyboard, and integrated productivity software. 036 * It runs proprietary HIMS firmware and includes word processing, email, web browsing, 037 * media playback, and educational applications.</p> 038 * 039 * <p><b>Assessment Phases (12 domains, 52 skills):</b></p> 040 * <ul> 041 * <li><b>Phase 1:</b> Device fundamentals (layout, setup, navigation, file management, core apps)</li> 042 * <li><b>Phase 2:</b> Productivity suite (calendar, email, web, calculator, word processor)</li> 043 * <li><b>Phase 3:</b> Advanced apps (presentations, code editor, third-party integration, braille I/O)</li> 044 * <li><b>Phase 4:</b> Cloud integration and advanced file management</li> 045 * <li><b>Phase 5:</b> Collaboration, export/import, printing, backup workflows</li> 046 * <li><b>Phase 6:</b> App installation, updates, troubleshooting</li> 047 * <li><b>Phase 7:</b> Automation (custom shortcuts, macros, scripting)</li> 048 * <li><b>Phase 8:</b> Peripheral connectivity (Bluetooth, USB, displays, audio/video)</li> 049 * <li><b>Phase 9:</b> Security, user accounts, parental controls, network settings</li> 050 * <li><b>Phase 10:</b> Speech customization (TTS settings, voice profiles, languages)</li> 051 * <li><b>Phase 11:</b> Device maintenance (firmware, diagnostics, logs, support, warranty)</li> 052 * <li><b>Phase 12:</b> Community resources (online help, forums, feedback channels)</li> 053 * </ul> 054 * 055 * <p><b>Data Management and Report Generation:</b></p> 056 * <ul> 057 * <li>Scores captured via {@link PhaseScoreField} components (integer 0–4 typical)</li> 058 * <li>Persisted to normalized schema via {@link com.studentgui.apphelpers.Database#insertAssessmentResults}</li> 059 * <li>JSON export: {@code StudentDataFiles/<student>/Sessions/BrailleSense/BrailleSense-<sessionId>-<timestamp>.json}</li> 060 * <li>Phase-grouped time-series plots: {@code plots/BrailleSense-<sessionId>-<date>-P<N>.png} (12 phase groups)</li> 061 * <li>Markdown and HTML reports with embedded plots and color-coded legends</li> 062 * </ul> 063 * 064 * <p>The shared {@link JLineGraph} visualizes recent session trends grouped by phase prefix. 065 * This page operates on static student/date parameters and does not implement listener interfaces.</p> 066 * 067 * @see com.studentgui.apphelpers.Database 068 * @see JLineGraph 069 * @see PhaseScoreField 070 * @see BrailleNote 071 */ 072public class BrailleSense extends JPanel { 073 private static final Logger LOG = LoggerFactory.getLogger(BrailleSense.class); 074 /** Map of assessment part codes to their input components. */ 075 private final Map<String, PhaseScoreField> inputs = new LinkedHashMap<>(); 076 /** Canonical assessment parts for BrailleSense. */ 077 private final String[][] parts; 078 /** Selected student display name (may be null). */ 079 private final String studentNameParam; 080 /** Date associated with the current session. */ 081 private final LocalDate dateParam; 082 /** Shared graph component used to visualize recent results. */ 083 private final JLineGraph graph; 084 085 /** 086 * Create a BrailleSense page bound to the provided student and date. 087 * 088 * @param studentName selected student name (may be null until selection) 089 * @param date session date to associate with persisted progress rows 090 * @param graph shared graph component used to plot recent results 091 */ 092 public BrailleSense(String studentName, LocalDate date, JLineGraph graph) { 093 this.studentNameParam = (studentName == null || studentName.trim().isEmpty()) ? com.studentgui.apphelpers.Helpers.defaultStudent() : studentName; 094 this.dateParam = date; 095 this.graph = graph; 096 setLayout(new BorderLayout()); 097 098 // create a data entry panel that mirrors BrailleNote's layout so alignment is identical 099 JPanel dataEntryPanel = new JPanel(new GridBagLayout()); 100 JPanel view = new JPanel(new BorderLayout()); 101 view.add(dataEntryPanel, BorderLayout.NORTH); 102 view.setBorder(javax.swing.BorderFactory.createEmptyBorder(20, 20, 20, 20)); 103 JScrollPane dataEntryScrollPane = new JScrollPane(view); 104 dataEntryScrollPane.setVerticalScrollBarPolicy(javax.swing.JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED); 105 dataEntryScrollPane.setHorizontalScrollBarPolicy(javax.swing.JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); 106 dataEntryScrollPane.getAccessibleContext().setAccessibleName("BrailleSense data entry scroll pane"); 107 108 GridBagConstraints gbc = new GridBagConstraints(); 109 gbc.insets = new Insets(2, 2, 2, 2); 110 gbc.fill = GridBagConstraints.HORIZONTAL; 111 gbc.weightx = 1.0; 112 gbc.weighty = 0.0; 113 114 JLabel titleLabel = new JLabel("BrailleSense Skills"); 115 // Use an explicit font so theme changes don't alter the title appearance 116 titleLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 28)); 117 gbc.gridx = 0; 118 gbc.gridy = 0; 119 gbc.gridwidth = GridBagConstraints.REMAINDER; 120 dataEntryPanel.add(titleLabel, gbc); 121 122 gbc.gridy = 1; 123 gbc.gridwidth = GridBagConstraints.REMAINDER; 124 gbc.ipady = 20; 125 dataEntryPanel.add(new JPanel(), gbc); 126 127 this.parts = new String[][]{ 128 {"P1_1", "1.1 Physical Layout"}, {"P1_2", "1.2 Setup/Universal Commands"}, {"P1_3", "1.3 BNT+ Navigation"}, {"P1_4", "1.4 File Management"}, {"P1_5", "1.5 Word Processor"}, {"P1_6", "1.6 Email"}, {"P1_7", "1.7 Internet"}, {"P1_8", "1.8 Calculator"}, {"P1_9", "1.9 KeyMath"}, 129 {"P2_1", "2.1 Calendar"}, {"P2_2", "2.2 KeyBRF"}, {"P2_3", "2.3 KeyFiles"}, {"P2_4", "2.4 KeyMail"}, {"P2_5", "2.5 KeyWeb"}, {"P2_6", "2.6 KeyCalc"}, {"P2_7", "2.7 KeyWord"}, 130 {"P3_1", "3.1 KeySlides"}, {"P3_2", "3.2 KeyCode"}, {"P3_3", "3.3 Third Party Apps"}, {"P3_4", "3.4 Braille Input"}, {"P3_5", "3.5 Braille Output"}, {"P3_6", "3.6 Settings"}, {"P3_7", "3.7 Accessibility"}, 131 {"P4_1", "4.1 Advanced File Management"}, {"P4_2", "4.2 Cloud Integration"}, {"P4_3", "4.3 Device Maintenance"}, 132 {"P5_1", "5.1 Collaboration"}, {"P5_2", "5.2 Export/Import"}, {"P5_3", "5.3 Printing"}, {"P5_4", "5.4 Backup"}, 133 {"P6_1", "6.1 App Installation"}, {"P6_2", "6.2 App Updates"}, {"P6_3", "6.3 Troubleshooting"}, 134 {"P7_1", "7.1 Custom Shortcuts"}, {"P7_2", "7.2 Macros"}, {"P7_3", "7.3 Scripting"}, {"P7_4", "7.4 Automation"}, 135 {"P8_1", "8.1 Bluetooth Devices"}, {"P8_2", "8.2 USB Devices"}, {"P8_3", "8.3 External Displays"}, {"P8_4", "8.4 Audio Output"}, {"P8_5", "8.5 Video Output"}, 136 {"P9_1", "9.1 Security"}, {"P9_2", "9.2 User Accounts"}, {"P9_3", "9.3 Parental Controls"}, {"P9_4", "9.4 Network Settings"}, 137 {"P10_1", "10.1 Speech Settings"}, {"P10_2", "10.2 Voice Profiles"}, {"P10_3", "10.3 Language Support"}, 138 {"P11_1", "11.1 Firmware Updates"}, {"P11_2", "11.2 Diagnostics"}, {"P11_3", "11.3 Logs"}, {"P11_4", "11.4 Support"}, {"P11_5", "11.5 Warranty"}, 139 {"P12_1", "12.1 Community Resources"}, {"P12_2", "12.2 Online Help"}, {"P12_3", "12.3 User Forums"}, {"P12_4", "12.4 Feedback"} 140 }; 141 142 // compute pixel width using font metrics so labels align precisely 143 String[] labels = java.util.Arrays.stream(this.parts).map(x -> x[1]).toArray(String[]::new); 144 int maxPx = com.studentgui.uicomp.PhaseScoreField.computeMaxLabelPixelWidth(com.studentgui.uicomp.PhaseScoreField.getLabelFont(), labels); 145 com.studentgui.uicomp.PhaseScoreField.setGlobalLabelWidth(Math.min(360, Math.max(200, maxPx + 50))); 146 int row = 1; 147 for (String[] def : this.parts) { 148 gbc.gridx = 0; 149 gbc.gridy = row; 150 gbc.gridwidth = 2; 151 PhaseScoreField tf = new PhaseScoreField(def[1], 0); 152 tf.setName("braillesense_" + def[0]); 153 tf.getAccessibleContext().setAccessibleName(def[1]); 154 tf.setToolTipText("Enter score for " + def[1]); 155 dataEntryPanel.add(tf, gbc); 156 inputs.put(def[0], tf); 157 row++; 158 } 159 160 // Place Submit and Open Latest side-by-side to match IOS/ScreenReader styling 161 gbc.gridx = 0; 162 gbc.gridy = row; 163 gbc.gridwidth = 1; 164 gbc.anchor = GridBagConstraints.WEST; 165 JButton submit = new JButton("Submit Data"); 166 submit.setPreferredSize(new java.awt.Dimension(0, 32)); 167 submit.addActionListener((ActionEvent e) -> save()); 168 submit.setMnemonic(KeyEvent.VK_S); 169 submit.setToolTipText("Save BrailleSense scores (Alt+S)"); 170 submit.getAccessibleContext().setAccessibleName("Submit BrailleSense Data"); 171 submit.setName("braillesense_submit"); 172 dataEntryPanel.add(submit, gbc); 173 174 gbc.gridx = 1; 175 gbc.gridwidth = 1; 176 gbc.anchor = GridBagConstraints.WEST; 177 JButton openLatest = new JButton("Open Latest Plot"); 178 openLatest.setPreferredSize(new java.awt.Dimension(0, 32)); 179 openLatest.addActionListener((ActionEvent e) -> { 180 java.nio.file.Path p = com.studentgui.apphelpers.Helpers.latestPlotPath(this.studentNameParam, "BrailleSense"); 181 if (p == null) { 182 com.studentgui.apphelpers.UiNotifier.show("No BrailleSense plot found for student"); 183 } else { 184 try { 185 java.awt.Desktop.getDesktop().open(p.toFile()); 186 } catch (java.io.IOException | UnsupportedOperationException | SecurityException ex) { 187 com.studentgui.apphelpers.UiNotifier.show("Unable to open plot: " + p.getFileName().toString()); 188 } 189 } 190 }); 191 dataEntryPanel.add(openLatest, gbc); 192 193 // Filler to consume remaining horizontal space 194 gbc.gridx = 2; 195 gbc.gridwidth = GridBagConstraints.REMAINDER; 196 gbc.anchor = GridBagConstraints.WEST; 197 dataEntryPanel.add(new JPanel(), gbc); 198 199 dataEntryScrollPane.getAccessibleContext().setAccessibleName("BrailleSense data entry scroll pane"); 200 add(dataEntryScrollPane, BorderLayout.CENTER); 201 add(graph, BorderLayout.SOUTH); 202 SwingUtilities.invokeLater(() -> { 203 dataEntryPanel.setPreferredSize(dataEntryPanel.getPreferredSize()); 204 revalidate(); 205 }); 206 SwingUtilities.invokeLater(() -> { 207 for (var e : inputs.values()) { 208 LOG.debug("BrailleSense field {} labelWidth={} spinnerX={} gap={}", e.getLabel(), e.getLabelWrapWidth(), e.getSpinnerX(), e.getActualGap()); 209 } 210 }); 211 com.studentgui.apphelpers.Helpers.createFolderHierarchy(); 212 initParts(); 213 } 214 215 /** 216 * Ensure the database contains the progress-type and assessment part rows 217 * for BrailleSense. Safe to call repeatedly. 218 */ 219 private void initParts() { 220 try { 221 int ptId = com.studentgui.apphelpers.Database.getOrCreateProgressType("BrailleSense"); 222 String[] codes = inputs.keySet().toArray(String[]::new); 223 com.studentgui.apphelpers.Database.ensureAssessmentParts(ptId, codes); 224 } catch (SQLException ex) { 225 LOG.error("Error ensuring braillesense parts", ex); 226 } 227 } 228 229 /** 230 * Persist the current inputs as a new progress session for the selected 231 * student. Non-integer input is treated as zero. 232 */ 233 private void save() { 234 try { 235 int studentId = com.studentgui.apphelpers.Database.getOrCreateStudent(studentNameParam); 236 int ptId = com.studentgui.apphelpers.Database.getOrCreateProgressType("BrailleSense"); 237 int sessionId = com.studentgui.apphelpers.Database.createProgressSession(studentId, ptId, dateParam); 238 String[] codes = inputs.keySet().toArray(String[]::new); 239 int[] scores = new int[codes.length]; 240 for (int i = 0; i < codes.length; i++) { 241 scores[i] = inputs.get(codes[i]).getValue(); 242 } 243 com.studentgui.apphelpers.Database.insertAssessmentResults(sessionId, ptId, codes, scores); 244 LOG.info("BrailleSense data saved for {}", studentNameParam); 245 com.studentgui.apphelpers.UiNotifier.show("BrailleSense data saved."); 246 com.studentgui.apphelpers.dto.AssessmentPayload payload = new com.studentgui.apphelpers.dto.AssessmentPayload(sessionId, codes, scores); 247 java.nio.file.Path jsonOut = com.studentgui.apphelpers.SessionJsonWriter.writeSessionJson(this.studentNameParam, "BrailleSense", payload, sessionId); 248 if (jsonOut == null) { 249 LOG.warn("Unable to save BrailleSense session JSON for sessionId={}", sessionId); 250 } 251 try { 252 java.nio.file.Path plotsOut = com.studentgui.apphelpers.Helpers.studentPlotsDir(this.studentNameParam); 253 java.nio.file.Path reportsOut = com.studentgui.apphelpers.Helpers.studentReportsDir(this.studentNameParam); 254 java.nio.file.Files.createDirectories(plotsOut); 255 java.nio.file.Files.createDirectories(reportsOut); 256 java.time.format.DateTimeFormatter df = java.time.format.DateTimeFormatter.ISO_DATE; 257 String dateStr = this.dateParam != null ? this.dateParam.format(df) : java.time.LocalDate.now().toString(); 258 String baseName = "BrailleSense-" + sessionId + "-" + dateStr; 259 260 com.studentgui.apphelpers.Database.ResultsWithDates rwd = com.studentgui.apphelpers.Database.fetchLatestAssessmentResultsWithDates(this.studentNameParam, "BrailleSense", Integer.MAX_VALUE); 261 java.util.Map<String, java.nio.file.Path> groups = null; 262 String[] labels = java.util.Arrays.stream(this.parts).map(x -> x[1]).toArray(String[]::new); 263 if (rwd != null && rwd.rows != null && !rwd.rows.isEmpty()) { 264 graph.updateWithGroupedDataByDate(rwd.dates, rwd.rows, codes, labels); 265 groups = graph.saveGroupedCharts(plotsOut, baseName, 1000, 240); 266 java.time.LocalDate headerDate = rwd.dates.get(rwd.dates.size() - 1); 267 dateStr = headerDate.format(df); 268 } else { 269 java.util.List<java.util.List<Integer>> rowsList = new java.util.ArrayList<>(); 270 java.util.List<Integer> latest = new java.util.ArrayList<>(); 271 for (int i = 0; i < codes.length; i++) { 272 latest.add(inputs.get(codes[i]).getValue()); 273 } 274 rowsList.add(latest); 275 graph.updateWithGroupedData(rowsList, codes); 276 groups = graph.saveGroupedCharts(plotsOut, baseName, 1000, 240); 277 } 278 279 if (groups == null) { 280 groups = new java.util.LinkedHashMap<>(); 281 } 282 StringBuilder md = new StringBuilder(); 283 md.append("# ").append(this.studentNameParam == null ? "Unknown Student" : this.studentNameParam).append(" - ").append(dateStr).append("\n\n"); 284 for (java.util.Map.Entry<String, java.nio.file.Path> e : groups.entrySet()) { 285 md.append("## ").append(e.getKey()).append("\n\n"); 286 md.append(".append(e.getValue().getFileName().toString()).append(")\n\n"); 287 } 288 // adjust markdown image links to point to the plots folder relative to reports 289 java.lang.String mdText = md.toString().replace("; 290 java.nio.file.Path mdFile = reportsOut.resolve(baseName + ".md"); 291 java.nio.file.Files.writeString(mdFile, mdText, java.nio.charset.StandardCharsets.UTF_8); 292 293 try { 294 String[] palette = JLineGraph.PALETTE_HEX; 295 java.util.LinkedHashMap<String, java.util.List<Integer>> groupsIdx = new java.util.LinkedHashMap<>(); 296 for (int i = 0; i < codes.length; i++) { 297 String code = codes[i]; 298 String grp = code != null && code.contains("_") ? code.split("_")[0] : code; 299 groupsIdx.computeIfAbsent(grp, k -> new java.util.ArrayList<>()).add(i); 300 } 301 StringBuilder html = new StringBuilder(); 302 html.append("<!doctype html><html><head><meta charset=\"utf-8\"><title>"); 303 html.append(this.studentNameParam == null ? "Student Report" : this.studentNameParam).append(" - ").append(dateStr).append("</title>"); 304 html.append("<style>body{font-family:sans-serif;margin:20px;} img{max-width:100%;height:auto;border:1px solid #ccc;margin-bottom:8px;} .legend{max-height:160px;overflow:auto;border:1px solid #ddd;padding:8px;margin-bottom:24px;} .legend-item{display:flex;align-items:center;gap:8px;padding:4px 0;} .swatch{width:18px;height:12px;border:1px solid #333;display:inline-block}</style>"); 305 html.append("</head><body>"); 306 html.append("<h1>").append(this.studentNameParam == null ? "Unknown Student" : this.studentNameParam).append(" - ").append(dateStr).append("</h1>"); 307 for (java.util.Map.Entry<String, java.nio.file.Path> e2 : groups.entrySet()) { 308 String grp = e2.getKey(); 309 String imgName = e2.getValue().getFileName().toString(); 310 html.append("<h2>").append(grp).append("</h2>"); 311 html.append("<div class=\"plot\"><img src=\"../plots/").append(imgName).append("\" alt=\"").append(grp).append("\"></div>"); 312 java.util.List<Integer> idxs = groupsIdx.getOrDefault(grp, new java.util.ArrayList<>()); 313 html.append("<div class=\"legend\">"); 314 for (int s = 0; s < idxs.size(); s++) { 315 int idx = idxs.get(s); 316 String code = codes[idx]; 317 String human = this.parts[idx][1]; 318 String seriesName = code + " - " + human; 319 String color = palette[s % palette.length]; 320 html.append("<div class=\"legend-item\">"); 321 html.append("<span class=\"swatch\" style=\"background:"); 322 html.append(color); 323 html.append(";\"></span>"); 324 html.append("<div>"); 325 html.append(seriesName); 326 html.append("</div></div>"); 327 } 328 html.append("</div>"); 329 } 330 html.append("</body></html>"); 331 java.nio.file.Path htmlFile = reportsOut.resolve(baseName + ".html"); 332 java.nio.file.Files.writeString(htmlFile, html.toString(), java.nio.charset.StandardCharsets.UTF_8); 333 LOG.info("Wrote BrailleSense HTML session report {}", htmlFile); 334 } catch (java.io.IOException ioex) { 335 LOG.warn("Unable to write BrailleSense HTML report: {}", ioex.toString()); 336 } 337 } catch (java.io.IOException ioe) { 338 LOG.warn("Unable to save BrailleSense per-phase plots or markdown report: {}", ioe.toString()); 339 } 340 } catch (SQLException ex) { 341 LOG.error("Error saving braillesense data", ex); 342 } 343 } 344 345 // plotting is handled via submit/save which updates the shared graph and saves a static PNG 346}