mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.3.11
噪音数据历史记录,引入数据库
This commit is contained in:
@@ -20,6 +20,7 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons";
|
public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons";
|
||||||
public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity";
|
public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity";
|
||||||
public const string DesktopStudySessionControl = "DesktopStudySessionControl";
|
public const string DesktopStudySessionControl = "DesktopStudySessionControl";
|
||||||
|
public const string DesktopStudySessionHistory = "DesktopStudySessionHistory";
|
||||||
public const string Blank2x4 = "Blank2x4";
|
public const string Blank2x4 = "Blank2x4";
|
||||||
public const string Date = "Date";
|
public const string Date = "Date";
|
||||||
public const string MonthCalendar = "MonthCalendar";
|
public const string MonthCalendar = "MonthCalendar";
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 1,
|
MinHeightCells: 1,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopStudySessionHistory,
|
||||||
|
"Session History",
|
||||||
|
"History",
|
||||||
|
"Study",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
||||||
"Noise Curve",
|
"Noise Curve",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
"component.study_session_control": "Study Session Control",
|
"component.study_session_control": "Study Session Control",
|
||||||
|
"component.study_session_history": "Session History",
|
||||||
"component.study_noise_curve": "Noise Curve",
|
"component.study_noise_curve": "Noise Curve",
|
||||||
"component.study_noise_distribution": "Noise Distribution",
|
"component.study_noise_distribution": "Noise Distribution",
|
||||||
"component.study_score_overview": "Study Score Overview",
|
"component.study_score_overview": "Study Score Overview",
|
||||||
@@ -300,6 +301,26 @@
|
|||||||
"study.session_control.last_session_format": "Last {0}",
|
"study.session_control.last_session_format": "Last {0}",
|
||||||
"study.session_control.start_failed": "Unable to start session",
|
"study.session_control.start_failed": "Unable to start session",
|
||||||
"study.session_control.stop_failed": "Unable to stop session",
|
"study.session_control.stop_failed": "Unable to stop session",
|
||||||
|
"study.session_history.title": "Session History",
|
||||||
|
"study.session_history.empty": "No session history",
|
||||||
|
"study.session_history.select_failed": "Unable to switch session",
|
||||||
|
"study.session_history.rename_failed": "Unable to rename session",
|
||||||
|
"study.session_history.delete_failed": "Unable to delete session",
|
||||||
|
"study.session_history.rename_placeholder": "Enter session name",
|
||||||
|
"study.session_history.rename_confirm": "Confirm rename",
|
||||||
|
"study.session_history.rename_cancel": "Cancel rename",
|
||||||
|
"study.session_history.loading": "Loading data...",
|
||||||
|
"study.session_history.loaded": "Data loaded",
|
||||||
|
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
|
||||||
|
"study.session_history.meta_format": "{0} · Avg {1:F1}",
|
||||||
|
"study.session_history.action.view": "View",
|
||||||
|
"study.session_history.action.rename": "Rename",
|
||||||
|
"study.session_history.action.delete": "Delete",
|
||||||
|
"study.session_history.dialog.rename_title": "Rename Session",
|
||||||
|
"study.session_history.dialog.rename_message": "Enter a new name for \"{0}\".",
|
||||||
|
"study.session_history.dialog.delete_title": "Delete Session",
|
||||||
|
"study.session_history.dialog.delete_message": "Delete \"{0}\"? This cannot be undone.",
|
||||||
|
"study.session_history.dialog.delete_confirm": "Delete",
|
||||||
"study.noise_curve.value_format": "{0:F1} dB",
|
"study.noise_curve.value_format": "{0:F1} dB",
|
||||||
"study.noise_curve.axis.now": "Now",
|
"study.noise_curve.axis.now": "Now",
|
||||||
"study.noise_distribution.title": "Noise Level Distribution",
|
"study.noise_distribution.title": "Noise Level Distribution",
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"component.study_environment": "环境",
|
"component.study_environment": "环境",
|
||||||
"component.study_session_control": "自习时段控制",
|
"component.study_session_control": "自习时段控制",
|
||||||
|
"component.study_session_history": "历史时段数据",
|
||||||
"component.study_noise_curve": "噪音曲线",
|
"component.study_noise_curve": "噪音曲线",
|
||||||
"component.study_noise_distribution": "噪音等级分布",
|
"component.study_noise_distribution": "噪音等级分布",
|
||||||
"component.study_score_overview": "自习评分总览",
|
"component.study_score_overview": "自习评分总览",
|
||||||
@@ -300,6 +301,26 @@
|
|||||||
"study.session_control.last_session_format": "上次时段 {0}",
|
"study.session_control.last_session_format": "上次时段 {0}",
|
||||||
"study.session_control.start_failed": "启动失败",
|
"study.session_control.start_failed": "启动失败",
|
||||||
"study.session_control.stop_failed": "结束失败",
|
"study.session_control.stop_failed": "结束失败",
|
||||||
|
"study.session_history.title": "历史时段",
|
||||||
|
"study.session_history.empty": "暂无历史时段",
|
||||||
|
"study.session_history.select_failed": "切换失败",
|
||||||
|
"study.session_history.rename_failed": "重命名失败",
|
||||||
|
"study.session_history.delete_failed": "删除失败",
|
||||||
|
"study.session_history.rename_placeholder": "输入时段名称",
|
||||||
|
"study.session_history.rename_confirm": "确认重命名",
|
||||||
|
"study.session_history.rename_cancel": "取消重命名",
|
||||||
|
"study.session_history.loading": "加载数据中...",
|
||||||
|
"study.session_history.loaded": "数据已加载",
|
||||||
|
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
|
||||||
|
"study.session_history.meta_format": "{0} · 均分 {1:F1}",
|
||||||
|
"study.session_history.action.view": "查看",
|
||||||
|
"study.session_history.action.rename": "重命名",
|
||||||
|
"study.session_history.action.delete": "删除",
|
||||||
|
"study.session_history.dialog.rename_title": "重命名时段",
|
||||||
|
"study.session_history.dialog.rename_message": "请为“{0}”输入新名称。",
|
||||||
|
"study.session_history.dialog.delete_title": "删除时段",
|
||||||
|
"study.session_history.dialog.delete_message": "确认删除“{0}”?此操作无法撤销。",
|
||||||
|
"study.session_history.dialog.delete_confirm": "确认删除",
|
||||||
"study.noise_curve.value_format": "{0:F1} dB",
|
"study.noise_curve.value_format": "{0:F1} dB",
|
||||||
"study.noise_curve.axis.now": "现在",
|
"study.noise_curve.axis.now": "现在",
|
||||||
"study.noise_distribution.title": "噪音等级分布",
|
"study.noise_distribution.title": "噪音等级分布",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Models;
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
@@ -75,4 +75,5 @@ public sealed class AppSettingsSnapshot
|
|||||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||||
|
|
||||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
20
LanMountainDesktop/Models/AttendanceModels.cs
Normal file
20
LanMountainDesktop/Models/AttendanceModels.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public sealed record AttendanceSessionRecord(
|
||||||
|
string SessionId,
|
||||||
|
string Label,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
DateTimeOffset? EndedAt,
|
||||||
|
string Status,
|
||||||
|
double? Score,
|
||||||
|
string? PayloadJson);
|
||||||
|
|
||||||
|
public sealed record AttendanceEventRecord(
|
||||||
|
string EventId,
|
||||||
|
string SessionId,
|
||||||
|
string EventType,
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
string? PayloadJson);
|
||||||
|
|
||||||
@@ -92,6 +92,18 @@ public sealed record NoiseSliceSummary(
|
|||||||
double Score,
|
double Score,
|
||||||
NoiseScoreBreakdown ScoreDetail);
|
NoiseScoreBreakdown ScoreDetail);
|
||||||
|
|
||||||
|
public enum NoiseSliceSourceType
|
||||||
|
{
|
||||||
|
Realtime = 0,
|
||||||
|
Session = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record NoiseSliceTimelineEntry(
|
||||||
|
long TimelineId,
|
||||||
|
NoiseSliceSourceType SourceType,
|
||||||
|
string? SessionId,
|
||||||
|
NoiseSliceSummary Slice);
|
||||||
|
|
||||||
public sealed record StudySessionOptions(
|
public sealed record StudySessionOptions(
|
||||||
string? Label = null,
|
string? Label = null,
|
||||||
DateTimeOffset? PlannedEndAt = null);
|
DateTimeOffset? PlannedEndAt = null);
|
||||||
@@ -125,6 +137,15 @@ public sealed record StudySessionReport(
|
|||||||
StudySessionMetrics Metrics,
|
StudySessionMetrics Metrics,
|
||||||
IReadOnlyList<NoiseSliceSummary> Slices);
|
IReadOnlyList<NoiseSliceSummary> Slices);
|
||||||
|
|
||||||
|
public sealed record StudySessionHistoryEntry(
|
||||||
|
string SessionId,
|
||||||
|
string Label,
|
||||||
|
DateTimeOffset StartedAt,
|
||||||
|
DateTimeOffset EndedAt,
|
||||||
|
TimeSpan Duration,
|
||||||
|
double AverageScore,
|
||||||
|
int SliceCount);
|
||||||
|
|
||||||
public sealed record StudyAnalyticsSnapshot(
|
public sealed record StudyAnalyticsSnapshot(
|
||||||
StudyAnalyticsRuntimeState State,
|
StudyAnalyticsRuntimeState State,
|
||||||
NoiseStreamStatus StreamStatus,
|
NoiseStreamStatus StreamStatus,
|
||||||
@@ -135,4 +156,6 @@ public sealed record StudyAnalyticsSnapshot(
|
|||||||
IReadOnlyList<NoiseRealtimePoint> RealtimeBuffer,
|
IReadOnlyList<NoiseRealtimePoint> RealtimeBuffer,
|
||||||
StudySessionSnapshot Session,
|
StudySessionSnapshot Session,
|
||||||
StudySessionReport? LastSessionReport,
|
StudySessionReport? LastSessionReport,
|
||||||
|
string? SelectedSessionReportId,
|
||||||
|
IReadOnlyList<StudySessionHistoryEntry> SessionHistory,
|
||||||
string LastError);
|
string LastError);
|
||||||
|
|||||||
146
LanMountainDesktop/Services/AppDatabaseService.cs
Normal file
146
LanMountainDesktop/Services/AppDatabaseService.cs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public static class AppDatabaseServiceFactory
|
||||||
|
{
|
||||||
|
private static readonly Lazy<AppDatabaseService> SharedService = new(
|
||||||
|
() => new AppDatabaseService(),
|
||||||
|
isThreadSafe: true);
|
||||||
|
|
||||||
|
public static AppDatabaseService CreateDefault()
|
||||||
|
{
|
||||||
|
return SharedService.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppDatabaseService
|
||||||
|
{
|
||||||
|
private readonly object _schemaSyncRoot = new();
|
||||||
|
private readonly string _databasePath;
|
||||||
|
private bool _schemaInitialized;
|
||||||
|
|
||||||
|
public AppDatabaseService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
var dataDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||||
|
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
public SqliteConnection OpenConnection()
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_databasePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var connection = new SqliteConnection($"Data Source={_databasePath};Cache=Shared;Mode=ReadWriteCreate");
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureSchema(connection);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSchema(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
if (_schemaInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_schemaSyncRoot)
|
||||||
|
{
|
||||||
|
if (_schemaInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS study_session_reports (
|
||||||
|
session_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
started_at_utc_ms INTEGER NOT NULL,
|
||||||
|
ended_at_utc_ms INTEGER NOT NULL,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
avg_score REAL NOT NULL,
|
||||||
|
slice_count INTEGER NOT NULL,
|
||||||
|
report_json TEXT NOT NULL,
|
||||||
|
updated_at_utc_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_study_session_reports_ended_at
|
||||||
|
ON study_session_reports(ended_at_utc_ms DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS study_noise_slices (
|
||||||
|
timeline_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
start_at_utc_ms INTEGER NOT NULL,
|
||||||
|
end_at_utc_ms INTEGER NOT NULL,
|
||||||
|
source_type INTEGER NOT NULL,
|
||||||
|
session_id TEXT NULL,
|
||||||
|
score REAL NOT NULL,
|
||||||
|
avg_db REAL NOT NULL,
|
||||||
|
p95_db REAL NOT NULL,
|
||||||
|
p50_dbfs REAL NOT NULL,
|
||||||
|
over_ratio_dbfs REAL NOT NULL,
|
||||||
|
segment_count INTEGER NOT NULL,
|
||||||
|
slice_json TEXT NOT NULL,
|
||||||
|
created_at_utc_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_study_noise_slices_end_at
|
||||||
|
ON study_noise_slices(end_at_utc_ms DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_study_noise_slices_source_time
|
||||||
|
ON study_noise_slices(source_type, end_at_utc_ms DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||||
|
session_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
started_at_utc_ms INTEGER NOT NULL,
|
||||||
|
ended_at_utc_ms INTEGER NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
score REAL NULL,
|
||||||
|
payload_json TEXT NULL,
|
||||||
|
created_at_utc_ms INTEGER NOT NULL,
|
||||||
|
updated_at_utc_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_sessions_started_at
|
||||||
|
ON attendance_sessions(started_at_utc_ms DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attendance_events (
|
||||||
|
event_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
occurred_at_utc_ms INTEGER NOT NULL,
|
||||||
|
payload_json TEXT NULL,
|
||||||
|
created_at_utc_ms INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(session_id) REFERENCES attendance_sessions(session_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_events_session_time
|
||||||
|
ON attendance_events(session_id, occurred_at_utc_ms DESC);
|
||||||
|
""";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
_schemaInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
LanMountainDesktop/Services/AttendanceDataStore.cs
Normal file
130
LanMountainDesktop/Services/AttendanceDataStore.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class AttendanceDataStore
|
||||||
|
{
|
||||||
|
private readonly AppDatabaseService _databaseService;
|
||||||
|
|
||||||
|
public AttendanceDataStore(AppDatabaseService? databaseService = null)
|
||||||
|
{
|
||||||
|
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AttendanceSessionRecord> LoadSessions(int limit = 200)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = limit > 0
|
||||||
|
? """
|
||||||
|
SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json
|
||||||
|
FROM attendance_sessions
|
||||||
|
ORDER BY started_at_utc_ms DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json
|
||||||
|
FROM attendance_sessions
|
||||||
|
ORDER BY started_at_utc_ms DESC;
|
||||||
|
""";
|
||||||
|
if (limit > 0)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue("$limit", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions = new List<AttendanceSessionRecord>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var sessionId = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
|
||||||
|
var startedAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(2));
|
||||||
|
DateTimeOffset? endedAt = reader.IsDBNull(3)
|
||||||
|
? null
|
||||||
|
: DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3));
|
||||||
|
var status = reader.IsDBNull(4) ? string.Empty : reader.GetString(4);
|
||||||
|
double? score = reader.IsDBNull(5) ? null : reader.GetDouble(5);
|
||||||
|
var payload = reader.IsDBNull(6) ? null : reader.GetString(6);
|
||||||
|
|
||||||
|
sessions.Add(new AttendanceSessionRecord(
|
||||||
|
SessionId: sessionId,
|
||||||
|
Label: label,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
EndedAt: endedAt,
|
||||||
|
Status: status,
|
||||||
|
Score: score,
|
||||||
|
PayloadJson: payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<AttendanceSessionRecord>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpsertSession(AttendanceSessionRecord record)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO attendance_sessions(
|
||||||
|
session_id,
|
||||||
|
label,
|
||||||
|
started_at_utc_ms,
|
||||||
|
ended_at_utc_ms,
|
||||||
|
status,
|
||||||
|
score,
|
||||||
|
payload_json,
|
||||||
|
created_at_utc_ms,
|
||||||
|
updated_at_utc_ms)
|
||||||
|
VALUES(
|
||||||
|
$sessionId,
|
||||||
|
$label,
|
||||||
|
$startedAtUtcMs,
|
||||||
|
$endedAtUtcMs,
|
||||||
|
$status,
|
||||||
|
$score,
|
||||||
|
$payloadJson,
|
||||||
|
$createdAtUtcMs,
|
||||||
|
$updatedAtUtcMs)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
label = excluded.label,
|
||||||
|
started_at_utc_ms = excluded.started_at_utc_ms,
|
||||||
|
ended_at_utc_ms = excluded.ended_at_utc_ms,
|
||||||
|
status = excluded.status,
|
||||||
|
score = excluded.score,
|
||||||
|
payload_json = excluded.payload_json,
|
||||||
|
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||||
|
""";
|
||||||
|
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
command.Parameters.AddWithValue("$sessionId", record.SessionId);
|
||||||
|
command.Parameters.AddWithValue("$label", record.Label);
|
||||||
|
command.Parameters.AddWithValue("$startedAtUtcMs", record.StartedAt.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$endedAtUtcMs", record.EndedAt?.ToUnixTimeMilliseconds() ?? (object)DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$status", record.Status);
|
||||||
|
command.Parameters.AddWithValue("$score", record.Score ?? (object)DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$payloadJson", record.PayloadJson ?? (object)DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs);
|
||||||
|
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep runtime resilient when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
@@ -40,9 +41,25 @@ public interface IStudyAnalyticsService : IDisposable
|
|||||||
|
|
||||||
void ClearLastSessionReport();
|
void ClearLastSessionReport();
|
||||||
|
|
||||||
|
bool SelectSessionReport(string sessionId);
|
||||||
|
|
||||||
|
bool RenameSessionReport(string sessionId, string label);
|
||||||
|
|
||||||
|
bool DeleteSessionReport(string sessionId);
|
||||||
|
|
||||||
|
IReadOnlyList<NoiseSliceTimelineEntry> QueryNoiseSliceTimeline(
|
||||||
|
DateTimeOffset? startAt = null,
|
||||||
|
DateTimeOffset? endAt = null,
|
||||||
|
int limit = 720,
|
||||||
|
bool includeRealtimeSlices = true,
|
||||||
|
bool includeSessionSlices = true);
|
||||||
|
|
||||||
|
void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null);
|
||||||
|
|
||||||
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||||
|
|
||||||
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||||
|
|
||||||
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,8 @@ internal sealed class SessionAccumulator
|
|||||||
|
|
||||||
public bool IsRunning => _state == StudySessionRuntimeState.Running;
|
public bool IsRunning => _state == StudySessionRuntimeState.Running;
|
||||||
|
|
||||||
|
public string? CurrentSessionId => _sessionId;
|
||||||
|
|
||||||
public bool Start(DateTimeOffset now, StudySessionOptions options)
|
public bool Start(DateTimeOffset now, StudySessionOptions options)
|
||||||
{
|
{
|
||||||
if (IsRunning)
|
if (IsRunning)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
@@ -18,7 +20,9 @@ public static class StudyAnalyticsServiceFactory
|
|||||||
|
|
||||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||||
{
|
{
|
||||||
|
private const int MaxPersistedSessionReports = 120;
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
|
private readonly StudyDataStore _studyDataStore = new();
|
||||||
private readonly IAudioRecorderService _audioRecorderService;
|
private readonly IAudioRecorderService _audioRecorderService;
|
||||||
private readonly Timer _samplingTimer;
|
private readonly Timer _samplingTimer;
|
||||||
private readonly NoiseFramePipeline _pipeline;
|
private readonly NoiseFramePipeline _pipeline;
|
||||||
@@ -31,6 +35,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
private NoiseRealtimePoint? _latestRealtime;
|
private NoiseRealtimePoint? _latestRealtime;
|
||||||
private NoiseSliceSummary? _latestSlice;
|
private NoiseSliceSummary? _latestSlice;
|
||||||
private StudySessionReport? _lastSessionReport;
|
private StudySessionReport? _lastSessionReport;
|
||||||
|
private readonly List<StudySessionReport> _sessionHistory = [];
|
||||||
|
private string? _selectedSessionReportId;
|
||||||
private string _lastError = string.Empty;
|
private string _lastError = string.Empty;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
@@ -53,6 +59,9 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
_streamStatus = NoiseStreamStatus.Error;
|
_streamStatus = NoiseStreamStatus.Error;
|
||||||
_lastError = audioSnapshot.LastError;
|
_lastError = audioSnapshot.LastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RestoreSessionHistoryFromDatabaseLocked();
|
||||||
|
UpdateDataModeLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||||
@@ -172,7 +181,10 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
if (_sessionAccumulator.IsRunning)
|
if (_sessionAccumulator.IsRunning)
|
||||||
{
|
{
|
||||||
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||||
_lastSessionReport = finishedReport;
|
if (finishedReport is not null)
|
||||||
|
{
|
||||||
|
UpsertSessionReportLocked(finishedReport, selectReport: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateDataModeLocked();
|
UpdateDataModeLocked();
|
||||||
@@ -214,6 +226,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_lastSessionReport = null;
|
_lastSessionReport = null;
|
||||||
|
_selectedSessionReportId = null;
|
||||||
|
PersistSessionHistoryLocked();
|
||||||
UpdateDataModeLocked();
|
UpdateDataModeLocked();
|
||||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
started = true;
|
started = true;
|
||||||
@@ -237,7 +251,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastSessionReport = report;
|
UpsertSessionReportLocked(report, selectReport: true);
|
||||||
UpdateDataModeLocked();
|
UpdateDataModeLocked();
|
||||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
@@ -273,6 +287,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
{
|
{
|
||||||
ThrowIfDisposedLocked();
|
ThrowIfDisposedLocked();
|
||||||
_lastSessionReport = null;
|
_lastSessionReport = null;
|
||||||
|
_selectedSessionReportId = null;
|
||||||
|
PersistSessionHistoryLocked();
|
||||||
UpdateDataModeLocked();
|
UpdateDataModeLocked();
|
||||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
@@ -280,6 +296,144 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool SelectSessionReport(string sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
if (_sessionAccumulator.IsRunning)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryFindSessionReportLocked(sessionId, out var report))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedSessionReportId = report.SessionId;
|
||||||
|
_lastSessionReport = report;
|
||||||
|
PersistSessionHistoryLocked();
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool RenameSessionReport(string sessionId, string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedLabel = string.IsNullOrWhiteSpace(label)
|
||||||
|
? string.Empty
|
||||||
|
: label.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedLabel))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
var index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = _sessionHistory[index] with { Label = normalizedLabel };
|
||||||
|
_sessionHistory[index] = updated;
|
||||||
|
if (string.Equals(_selectedSessionReportId, updated.SessionId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_lastSessionReport = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistSessionHistoryLocked();
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteSessionReport(string sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StudyAnalyticsSnapshot snapshot;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
var index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = _sessionHistory[index];
|
||||||
|
_sessionHistory.RemoveAt(index);
|
||||||
|
|
||||||
|
if (string.Equals(_selectedSessionReportId, removed.SessionId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_selectedSessionReportId = null;
|
||||||
|
_lastSessionReport = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistSessionHistoryLocked();
|
||||||
|
UpdateDataModeLocked();
|
||||||
|
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<NoiseSliceTimelineEntry> QueryNoiseSliceTimeline(
|
||||||
|
DateTimeOffset? startAt = null,
|
||||||
|
DateTimeOffset? endAt = null,
|
||||||
|
int limit = 720,
|
||||||
|
bool includeRealtimeSlices = true,
|
||||||
|
bool includeSessionSlices = true)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _studyDataStore.LoadNoiseSliceTimeline(
|
||||||
|
startAt: startAt,
|
||||||
|
endAt: endAt,
|
||||||
|
limit: limit,
|
||||||
|
includeRealtimeSlices: includeRealtimeSlices,
|
||||||
|
includeSessionSlices: includeSessionSlices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ThrowIfDisposedLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
_studyDataStore.ClearNoiseSliceTimeline(olderThan);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
@@ -299,6 +453,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
{
|
{
|
||||||
StudyAnalyticsSnapshot? snapshot = null;
|
StudyAnalyticsSnapshot? snapshot = null;
|
||||||
NoiseSliceSummary? closedSlice = null;
|
NoiseSliceSummary? closedSlice = null;
|
||||||
|
string? closedSliceSessionId = null;
|
||||||
|
var closedSliceSourceType = NoiseSliceSourceType.Realtime;
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
|
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
|
||||||
@@ -350,6 +506,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
if (_sessionAccumulator.IsRunning)
|
if (_sessionAccumulator.IsRunning)
|
||||||
{
|
{
|
||||||
_sessionAccumulator.AddSlice(closedSlice);
|
_sessionAccumulator.AddSlice(closedSlice);
|
||||||
|
closedSliceSessionId = _sessionAccumulator.CurrentSessionId;
|
||||||
|
closedSliceSourceType = NoiseSliceSourceType.Session;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +517,14 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (closedSlice is not null)
|
||||||
|
{
|
||||||
|
_studyDataStore.AppendNoiseSlice(
|
||||||
|
slice: closedSlice,
|
||||||
|
sessionId: closedSliceSessionId,
|
||||||
|
sourceType: closedSliceSourceType);
|
||||||
|
}
|
||||||
|
|
||||||
if (snapshot is not null)
|
if (snapshot is not null)
|
||||||
{
|
{
|
||||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||||
@@ -425,6 +591,18 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
|
|
||||||
private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now)
|
private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now)
|
||||||
{
|
{
|
||||||
|
var historyEntries = _sessionHistory
|
||||||
|
.OrderByDescending(report => report.EndedAt)
|
||||||
|
.Select(report => new StudySessionHistoryEntry(
|
||||||
|
SessionId: report.SessionId,
|
||||||
|
Label: report.Label,
|
||||||
|
StartedAt: report.StartedAt,
|
||||||
|
EndedAt: report.EndedAt,
|
||||||
|
Duration: report.Duration,
|
||||||
|
AverageScore: report.Metrics.AvgScore,
|
||||||
|
SliceCount: report.Metrics.SliceCount))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return new StudyAnalyticsSnapshot(
|
return new StudyAnalyticsSnapshot(
|
||||||
State: _state,
|
State: _state,
|
||||||
StreamStatus: _streamStatus,
|
StreamStatus: _streamStatus,
|
||||||
@@ -435,6 +613,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
|
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
|
||||||
Session: _sessionAccumulator.GetSnapshot(now),
|
Session: _sessionAccumulator.GetSnapshot(now),
|
||||||
LastSessionReport: _lastSessionReport,
|
LastSessionReport: _lastSessionReport,
|
||||||
|
SelectedSessionReportId: _selectedSessionReportId,
|
||||||
|
SessionHistory: historyEntries,
|
||||||
LastError: _lastError);
|
LastError: _lastError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,4 +670,93 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
|||||||
throw new ObjectDisposedException(nameof(StudyAnalyticsService));
|
throw new ObjectDisposedException(nameof(StudyAnalyticsService));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpsertSessionReportLocked(StudySessionReport report, bool selectReport)
|
||||||
|
{
|
||||||
|
var index = FindSessionReportIndexLocked(report.SessionId);
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
_sessionHistory[index] = report;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_sessionHistory.Add(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
NormalizeSessionHistoryLocked();
|
||||||
|
|
||||||
|
if (selectReport)
|
||||||
|
{
|
||||||
|
_selectedSessionReportId = report.SessionId;
|
||||||
|
_lastSessionReport = report;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistSessionHistoryLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryFindSessionReportLocked(string sessionId, out StudySessionReport report)
|
||||||
|
{
|
||||||
|
var index = FindSessionReportIndexLocked(sessionId);
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
report = _sessionHistory[index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
report = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int FindSessionReportIndexLocked(string sessionId)
|
||||||
|
{
|
||||||
|
return _sessionHistory.FindIndex(report =>
|
||||||
|
string.Equals(report.SessionId, sessionId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NormalizeSessionHistoryLocked()
|
||||||
|
{
|
||||||
|
_sessionHistory.Sort((left, right) => right.EndedAt.CompareTo(left.EndedAt));
|
||||||
|
if (_sessionHistory.Count > MaxPersistedSessionReports)
|
||||||
|
{
|
||||||
|
_sessionHistory.RemoveRange(MaxPersistedSessionReports, _sessionHistory.Count - MaxPersistedSessionReports);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistSessionHistoryLocked()
|
||||||
|
{
|
||||||
|
var orderedReports = _sessionHistory
|
||||||
|
.OrderByDescending(report => report.EndedAt)
|
||||||
|
.Take(MaxPersistedSessionReports)
|
||||||
|
.ToList();
|
||||||
|
_studyDataStore.ReplaceSessionReports(orderedReports);
|
||||||
|
_studyDataStore.SetSelectedSessionReportId(_selectedSessionReportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreSessionHistoryFromDatabaseLocked()
|
||||||
|
{
|
||||||
|
_sessionHistory.Clear();
|
||||||
|
|
||||||
|
var restored = _studyDataStore.LoadSessionReports(MaxPersistedSessionReports)
|
||||||
|
.Where(report =>
|
||||||
|
!string.IsNullOrWhiteSpace(report.SessionId) &&
|
||||||
|
report.EndedAt >= report.StartedAt)
|
||||||
|
.GroupBy(report => report.SessionId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderByDescending(report => report.EndedAt)
|
||||||
|
.Take(MaxPersistedSessionReports);
|
||||||
|
_sessionHistory.AddRange(restored);
|
||||||
|
|
||||||
|
_selectedSessionReportId = _studyDataStore.GetSelectedSessionReportId();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_selectedSessionReportId) &&
|
||||||
|
TryFindSessionReportLocked(_selectedSessionReportId, out var selectedReport))
|
||||||
|
{
|
||||||
|
_lastSessionReport = selectedReport;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedSessionReportId = null;
|
||||||
|
_lastSessionReport = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
450
LanMountainDesktop/Services/StudyDataStore.cs
Normal file
450
LanMountainDesktop/Services/StudyDataStore.cs
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class StudyDataStore
|
||||||
|
{
|
||||||
|
private const string SelectedSessionReportIdMetaKey = "study.selected_session_report_id";
|
||||||
|
private const int DefaultNoiseSliceCapacity = 50000;
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly AppDatabaseService _databaseService;
|
||||||
|
|
||||||
|
public StudyDataStore(AppDatabaseService? databaseService = null)
|
||||||
|
{
|
||||||
|
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = limit > 0
|
||||||
|
? """
|
||||||
|
SELECT report_json
|
||||||
|
FROM study_session_reports
|
||||||
|
ORDER BY ended_at_utc_ms DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
SELECT report_json
|
||||||
|
FROM study_session_reports
|
||||||
|
ORDER BY ended_at_utc_ms DESC;
|
||||||
|
""";
|
||||||
|
if (limit > 0)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue("$limit", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var reports = new List<StudySessionReport>();
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(0))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = reader.GetString(0);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||||
|
if (report is not null)
|
||||||
|
{
|
||||||
|
reports.Add(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<StudySessionReport>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSessionReport(string sessionId, out StudySessionReport report)
|
||||||
|
{
|
||||||
|
report = null!;
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT report_json
|
||||||
|
FROM study_session_reports
|
||||||
|
WHERE session_id = $sessionId
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$sessionId", sessionId.Trim());
|
||||||
|
|
||||||
|
var json = command.ExecuteScalar() as string;
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||||
|
if (parsed is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
report = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplaceSessionReports(IReadOnlyList<StudySessionReport> reports)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
using (var clearCommand = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
clearCommand.Transaction = transaction;
|
||||||
|
clearCommand.CommandText = "DELETE FROM study_session_reports;";
|
||||||
|
clearCommand.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < reports.Count; i++)
|
||||||
|
{
|
||||||
|
InsertOrUpdateSessionReport(connection, transaction, reports[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep runtime resilient when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetSelectedSessionReportId()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT value
|
||||||
|
FROM app_meta
|
||||||
|
WHERE key = $key
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
|
||||||
|
var value = command.ExecuteScalar() as string;
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: value.Trim();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSelectedSessionReportId(string? sessionId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
using var deleteCommand = connection.CreateCommand();
|
||||||
|
deleteCommand.CommandText = "DELETE FROM app_meta WHERE key = $key;";
|
||||||
|
deleteCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
|
||||||
|
deleteCommand.ExecuteNonQuery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var upsertCommand = connection.CreateCommand();
|
||||||
|
upsertCommand.CommandText = """
|
||||||
|
INSERT INTO app_meta(key, value)
|
||||||
|
VALUES ($key, $value)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
|
||||||
|
""";
|
||||||
|
upsertCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
|
||||||
|
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
|
||||||
|
upsertCommand.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep runtime resilient when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AppendNoiseSlice(NoiseSliceSummary slice, string? sessionId, NoiseSliceSourceType sourceType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
var sliceJson = JsonSerializer.Serialize(slice, JsonOptions);
|
||||||
|
|
||||||
|
using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO study_noise_slices(
|
||||||
|
start_at_utc_ms,
|
||||||
|
end_at_utc_ms,
|
||||||
|
source_type,
|
||||||
|
session_id,
|
||||||
|
score,
|
||||||
|
avg_db,
|
||||||
|
p95_db,
|
||||||
|
p50_dbfs,
|
||||||
|
over_ratio_dbfs,
|
||||||
|
segment_count,
|
||||||
|
slice_json,
|
||||||
|
created_at_utc_ms)
|
||||||
|
VALUES(
|
||||||
|
$startAtUtcMs,
|
||||||
|
$endAtUtcMs,
|
||||||
|
$sourceType,
|
||||||
|
$sessionId,
|
||||||
|
$score,
|
||||||
|
$avgDb,
|
||||||
|
$p95Db,
|
||||||
|
$p50Dbfs,
|
||||||
|
$overRatioDbfs,
|
||||||
|
$segmentCount,
|
||||||
|
$sliceJson,
|
||||||
|
$createdAtUtcMs);
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$startAtUtcMs", slice.StartAt.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$endAtUtcMs", slice.EndAt.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$sourceType", (int)sourceType);
|
||||||
|
command.Parameters.AddWithValue("$sessionId", string.IsNullOrWhiteSpace(sessionId) ? (object)DBNull.Value : sessionId.Trim());
|
||||||
|
command.Parameters.AddWithValue("$score", slice.Score);
|
||||||
|
command.Parameters.AddWithValue("$avgDb", slice.Display.AvgDb);
|
||||||
|
command.Parameters.AddWithValue("$p95Db", slice.Display.P95Db);
|
||||||
|
command.Parameters.AddWithValue("$p50Dbfs", slice.Raw.P50Dbfs);
|
||||||
|
command.Parameters.AddWithValue("$overRatioDbfs", slice.Raw.OverRatioDbfs);
|
||||||
|
command.Parameters.AddWithValue("$segmentCount", Math.Max(0, slice.Raw.SegmentCount));
|
||||||
|
command.Parameters.AddWithValue("$sliceJson", sliceJson);
|
||||||
|
command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var trimCommand = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
trimCommand.Transaction = transaction;
|
||||||
|
trimCommand.CommandText = """
|
||||||
|
DELETE FROM study_noise_slices
|
||||||
|
WHERE timeline_id NOT IN (
|
||||||
|
SELECT timeline_id
|
||||||
|
FROM study_noise_slices
|
||||||
|
ORDER BY end_at_utc_ms DESC
|
||||||
|
LIMIT $limit
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
trimCommand.Parameters.AddWithValue("$limit", DefaultNoiseSliceCapacity);
|
||||||
|
trimCommand.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep runtime resilient when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<NoiseSliceTimelineEntry> LoadNoiseSliceTimeline(
|
||||||
|
DateTimeOffset? startAt = null,
|
||||||
|
DateTimeOffset? endAt = null,
|
||||||
|
int limit = 720,
|
||||||
|
bool includeRealtimeSlices = true,
|
||||||
|
bool includeSessionSlices = true)
|
||||||
|
{
|
||||||
|
if (!includeRealtimeSlices && !includeSessionSlices)
|
||||||
|
{
|
||||||
|
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
var whereParts = new List<string>();
|
||||||
|
if (startAt is not null)
|
||||||
|
{
|
||||||
|
whereParts.Add("end_at_utc_ms >= $startAtUtcMs");
|
||||||
|
command.Parameters.AddWithValue("$startAtUtcMs", startAt.Value.ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endAt is not null)
|
||||||
|
{
|
||||||
|
whereParts.Add("start_at_utc_ms <= $endAtUtcMs");
|
||||||
|
command.Parameters.AddWithValue("$endAtUtcMs", endAt.Value.ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeRealtimeSlices != includeSessionSlices)
|
||||||
|
{
|
||||||
|
var sourceType = includeSessionSlices
|
||||||
|
? (int)NoiseSliceSourceType.Session
|
||||||
|
: (int)NoiseSliceSourceType.Realtime;
|
||||||
|
whereParts.Add("source_type = $sourceType");
|
||||||
|
command.Parameters.AddWithValue("$sourceType", sourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
var whereClause = whereParts.Count == 0
|
||||||
|
? string.Empty
|
||||||
|
: $"WHERE {string.Join(" AND ", whereParts)}";
|
||||||
|
var normalizedLimit = Math.Clamp(limit, 1, DefaultNoiseSliceCapacity);
|
||||||
|
|
||||||
|
command.CommandText = $"""
|
||||||
|
SELECT timeline_id, source_type, session_id, slice_json
|
||||||
|
FROM study_noise_slices
|
||||||
|
{whereClause}
|
||||||
|
ORDER BY end_at_utc_ms DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$limit", normalizedLimit);
|
||||||
|
|
||||||
|
var entries = new List<NoiseSliceTimelineEntry>(Math.Min(normalizedLimit, 2048));
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var timelineId = reader.GetInt64(0);
|
||||||
|
var sourceTypeRaw = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
|
||||||
|
var sourceType = sourceTypeRaw == (int)NoiseSliceSourceType.Session
|
||||||
|
? NoiseSliceSourceType.Session
|
||||||
|
: NoiseSliceSourceType.Realtime;
|
||||||
|
var sessionId = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||||
|
if (reader.IsDBNull(3))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sliceJson = reader.GetString(3);
|
||||||
|
if (string.IsNullOrWhiteSpace(sliceJson))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slice = JsonSerializer.Deserialize<NoiseSliceSummary>(sliceJson, JsonOptions);
|
||||||
|
if (slice is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(new NoiseSliceTimelineEntry(
|
||||||
|
TimelineId: timelineId,
|
||||||
|
SourceType: sourceType,
|
||||||
|
SessionId: sessionId,
|
||||||
|
Slice: slice));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = _databaseService.OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
if (olderThan is null)
|
||||||
|
{
|
||||||
|
command.CommandText = "DELETE FROM study_noise_slices;";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
command.CommandText = "DELETE FROM study_noise_slices WHERE end_at_utc_ms <= $olderThanUtcMs;";
|
||||||
|
command.Parameters.AddWithValue("$olderThanUtcMs", olderThan.Value.ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep runtime resilient when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InsertOrUpdateSessionReport(
|
||||||
|
SqliteConnection connection,
|
||||||
|
SqliteTransaction transaction,
|
||||||
|
StudySessionReport report)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||||
|
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO study_session_reports(
|
||||||
|
session_id,
|
||||||
|
label,
|
||||||
|
started_at_utc_ms,
|
||||||
|
ended_at_utc_ms,
|
||||||
|
duration_ms,
|
||||||
|
avg_score,
|
||||||
|
slice_count,
|
||||||
|
report_json,
|
||||||
|
updated_at_utc_ms)
|
||||||
|
VALUES (
|
||||||
|
$sessionId,
|
||||||
|
$label,
|
||||||
|
$startedAtUtcMs,
|
||||||
|
$endedAtUtcMs,
|
||||||
|
$durationMs,
|
||||||
|
$avgScore,
|
||||||
|
$sliceCount,
|
||||||
|
$reportJson,
|
||||||
|
$updatedAtUtcMs)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
label = excluded.label,
|
||||||
|
started_at_utc_ms = excluded.started_at_utc_ms,
|
||||||
|
ended_at_utc_ms = excluded.ended_at_utc_ms,
|
||||||
|
duration_ms = excluded.duration_ms,
|
||||||
|
avg_score = excluded.avg_score,
|
||||||
|
slice_count = excluded.slice_count,
|
||||||
|
report_json = excluded.report_json,
|
||||||
|
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$sessionId", report.SessionId);
|
||||||
|
command.Parameters.AddWithValue("$label", report.Label);
|
||||||
|
command.Parameters.AddWithValue("$startedAtUtcMs", report.StartedAt.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$endedAtUtcMs", report.EndedAt.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$durationMs", (long)Math.Max(0, report.Duration.TotalMilliseconds));
|
||||||
|
command.Parameters.AddWithValue("$avgScore", report.Metrics.AvgScore);
|
||||||
|
command.Parameters.AddWithValue("$sliceCount", report.Metrics.SliceCount);
|
||||||
|
command.Parameters.AddWithValue("$reportJson", json);
|
||||||
|
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.study_session_control",
|
"component.study_session_control",
|
||||||
() => new StudySessionControlWidget(),
|
() => new StudySessionControlWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.36, 10, 24)),
|
cellSize => Math.Clamp(cellSize * 0.36, 10, 24)),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopStudySessionHistory,
|
||||||
|
"component.study_session_history",
|
||||||
|
() => new StudySessionHistoryWidget(),
|
||||||
|
cellSize => Math.Clamp(cellSize * 0.34, 10, 24)),
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
||||||
"component.study_noise_curve",
|
"component.study_noise_curve",
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="420"
|
||||||
|
d:DesignHeight="220"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
Classes="glass-strong"
|
||||||
|
CornerRadius="22"
|
||||||
|
Padding="12,10"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<Grid x:Name="ContentRootGrid"
|
||||||
|
RowDefinitions="Auto,*,Auto"
|
||||||
|
RowSpacing="8">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Grid.Row="0"
|
||||||
|
Text="Session History"
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
CornerRadius="10"
|
||||||
|
Background="#1AFFFFFF"
|
||||||
|
BorderBrush="#26FFFFFF"
|
||||||
|
BorderThickness="1"
|
||||||
|
Padding="6"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel x:Name="SessionListPanel"
|
||||||
|
Spacing="6" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
Grid.Row="2"
|
||||||
|
Text="No session history"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border x:Name="DialogOverlayBorder"
|
||||||
|
IsVisible="False"
|
||||||
|
Background="#70000000"
|
||||||
|
Padding="12"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
|
<Border x:Name="DialogCardBorder"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
CornerRadius="12"
|
||||||
|
BorderThickness="1"
|
||||||
|
Padding="12">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock x:Name="DialogTitleTextBlock"
|
||||||
|
Text="Dialog Title"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
MaxLines="1"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock x:Name="DialogMessageTextBlock"
|
||||||
|
Text="Dialog message"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBox x:Name="DialogRenameTextBox"
|
||||||
|
IsVisible="False"
|
||||||
|
Watermark="Enter session name"
|
||||||
|
MinWidth="120"
|
||||||
|
VerticalContentAlignment="Center" />
|
||||||
|
<Grid ColumnDefinitions="*,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Button x:Name="DialogCancelButton"
|
||||||
|
Grid.Column="0"
|
||||||
|
Content="Cancel"
|
||||||
|
CornerRadius="8"
|
||||||
|
Height="30" />
|
||||||
|
<Button x:Name="DialogConfirmButton"
|
||||||
|
Grid.Column="1"
|
||||||
|
Content="Confirm"
|
||||||
|
CornerRadius="8"
|
||||||
|
Height="30" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using FluentIcons.Avalonia;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Theme;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||||
|
{
|
||||||
|
private const double MinTextContrast = 4.5;
|
||||||
|
private enum HistoryDialogMode
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Rename = 1,
|
||||||
|
Delete = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Color[] PrimaryColorCandidates =
|
||||||
|
{
|
||||||
|
Color.Parse("#FFF8FAFC"),
|
||||||
|
Color.Parse("#FFEAF3FF"),
|
||||||
|
Color.Parse("#FF101C2A"),
|
||||||
|
Color.Parse("#FF1B2E45"),
|
||||||
|
Color.Parse("#FFFFFFFF")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Color[] SecondaryColorCandidates =
|
||||||
|
{
|
||||||
|
Color.Parse("#FFDDE7F3"),
|
||||||
|
Color.Parse("#FFCBD9EA"),
|
||||||
|
Color.Parse("#FF24384F"),
|
||||||
|
Color.Parse("#FF2F4763"),
|
||||||
|
Color.Parse("#FF0F1D2D")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
|
||||||
|
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||||
|
|
||||||
|
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||||
|
private readonly AppSettingsService _settingsService = new();
|
||||||
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
|
||||||
|
private double _currentCellSize = 48;
|
||||||
|
private string _languageCode = "zh-CN";
|
||||||
|
private bool _isAttached;
|
||||||
|
private bool _isOnActivePage = true;
|
||||||
|
private bool _isSubscribed;
|
||||||
|
private bool _isCompactMode;
|
||||||
|
private bool _isUltraCompactMode;
|
||||||
|
private string? _loadingSessionId;
|
||||||
|
private HistoryDialogMode _dialogMode;
|
||||||
|
private string? _dialogSessionId;
|
||||||
|
private string _dialogSessionLabel = string.Empty;
|
||||||
|
private StudyAnalyticsSnapshot? _currentSnapshot;
|
||||||
|
private string? _transientStatus;
|
||||||
|
private DateTimeOffset _transientStatusExpireAt;
|
||||||
|
|
||||||
|
public StudySessionHistoryWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
DialogCancelButton.Click += (_, _) => CloseDialog();
|
||||||
|
DialogConfirmButton.Click += (_, _) => ConfirmDialog();
|
||||||
|
DialogRenameTextBox.KeyDown += OnDialogRenameTextBoxKeyDown;
|
||||||
|
|
||||||
|
ReloadLanguageCode();
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||||
|
{
|
||||||
|
_ = isEditMode;
|
||||||
|
_isOnActivePage = isOnActivePage;
|
||||||
|
if (_isAttached && _isOnActivePage)
|
||||||
|
{
|
||||||
|
RefreshFromService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = true;
|
||||||
|
ReloadLanguageCode();
|
||||||
|
|
||||||
|
if (!_isSubscribed)
|
||||||
|
{
|
||||||
|
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||||
|
_isSubscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshFromService();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = false;
|
||||||
|
if (_isSubscribed)
|
||||||
|
{
|
||||||
|
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||||
|
_isSubscribed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_loadingSessionId) &&
|
||||||
|
string.Equals(e.Snapshot.SelectedSessionReportId, _loadingSessionId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_loadingSessionId = null;
|
||||||
|
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentSnapshot = e.Snapshot;
|
||||||
|
if (_isOnActivePage)
|
||||||
|
{
|
||||||
|
RenderSnapshot(e.Snapshot);
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshFromService()
|
||||||
|
{
|
||||||
|
_currentSnapshot = _studyAnalyticsService.GetSnapshot();
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderSnapshot(StudyAnalyticsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var panelColor = ResolvePanelBackgroundColor();
|
||||||
|
var panelSamples = BuildPanelBackgroundSamples(panelColor);
|
||||||
|
TitleTextBlock.Text = L("study.session_history.title", "Session History");
|
||||||
|
TitleTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, PrimaryColorCandidates, MinTextContrast);
|
||||||
|
|
||||||
|
if (_transientStatus is not null && DateTimeOffset.UtcNow > _transientStatusExpireAt)
|
||||||
|
{
|
||||||
|
_transientStatus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionListPanel.Children.Clear();
|
||||||
|
var history = snapshot.SessionHistory;
|
||||||
|
if (history.Count == 0)
|
||||||
|
{
|
||||||
|
if (_dialogMode != HistoryDialogMode.None)
|
||||||
|
{
|
||||||
|
CloseDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusTextBlock.Text = _transientStatus ?? L("study.session_history.empty", "No session history");
|
||||||
|
StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
|
||||||
|
UpdateDialogVisual(snapshot, panelColor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_dialogSessionId))
|
||||||
|
{
|
||||||
|
var dialogEntry = FindHistoryEntry(history, _dialogSessionId);
|
||||||
|
if (dialogEntry is null)
|
||||||
|
{
|
||||||
|
CloseDialog();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dialogSessionLabel = dialogEntry.Label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in history)
|
||||||
|
{
|
||||||
|
SessionListPanel.Children.Add(CreateSessionRow(entry, snapshot.SelectedSessionReportId, panelColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusTextBlock.Text = _transientStatus ?? string.Empty;
|
||||||
|
StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
|
||||||
|
UpdateDialogVisual(snapshot, panelColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control CreateSessionRow(StudySessionHistoryEntry entry, string? selectedSessionId, Color panelColor)
|
||||||
|
{
|
||||||
|
var isSelected = string.Equals(selectedSessionId, entry.SessionId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isLoading = string.Equals(_loadingSessionId, entry.SessionId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isDialogOpen = _dialogMode != HistoryDialogMode.None;
|
||||||
|
|
||||||
|
var rowBackground = isSelected
|
||||||
|
? Color.Parse("#4A5FA9FF")
|
||||||
|
: Color.Parse("#2CFFFFFF");
|
||||||
|
var rowBorderColor = isSelected
|
||||||
|
? Color.Parse("#99C7E0FF")
|
||||||
|
: Color.Parse("#33FFFFFF");
|
||||||
|
|
||||||
|
var rowBorder = new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.20, 8, 14)),
|
||||||
|
Background = new SolidColorBrush(rowBackground),
|
||||||
|
BorderBrush = new SolidColorBrush(rowBorderColor),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Padding = new Thickness(Math.Clamp(8, 6, 12), Math.Clamp(6, 4, 10))
|
||||||
|
};
|
||||||
|
|
||||||
|
var panelComposite = ToOpaqueAgainst(panelColor, DarkSubstrate);
|
||||||
|
var rowComposite = ToOpaqueAgainst(rowBackground, panelComposite);
|
||||||
|
var rowPrimaryBrush = CreateAdaptiveBrush(new[] { rowComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||||
|
var rowSecondaryBrush = CreateAdaptiveBrush(new[] { rowComposite }, SecondaryColorCandidates, MinTextContrast);
|
||||||
|
|
||||||
|
var rowGrid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto"),
|
||||||
|
ColumnSpacing = _isUltraCompactMode ? 4 : 6
|
||||||
|
};
|
||||||
|
|
||||||
|
var textStack = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = _isUltraCompactMode ? 0 : 2
|
||||||
|
};
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = entry.Label,
|
||||||
|
FontSize = Math.Clamp(12 * (_isCompactMode ? 0.92 : 1.0), 10, 17),
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
MaxLines = 1,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
Foreground = rowPrimaryBrush
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!_isUltraCompactMode)
|
||||||
|
{
|
||||||
|
var metaText = isLoading
|
||||||
|
? L("study.session_history.loading", "Loading data...")
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
L("study.session_history.meta_format", "{0} · Avg {1:F1}"),
|
||||||
|
FormatDuration(entry.Duration),
|
||||||
|
entry.AverageScore);
|
||||||
|
|
||||||
|
textStack.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = metaText,
|
||||||
|
FontSize = Math.Clamp(10.5 * (_isCompactMode ? 0.94 : 1.0), 9, 14),
|
||||||
|
MaxLines = 1,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
Foreground = rowSecondaryBrush
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rowGrid.Children.Add(textStack);
|
||||||
|
|
||||||
|
var playButton = CreateActionIconButton(
|
||||||
|
L("study.session_history.action.view", "View"),
|
||||||
|
Symbol.Play,
|
||||||
|
isLoading || isDialogOpen,
|
||||||
|
isSelected ? Color.Parse("#5A7FDEFF") : Color.Parse("#3D649FFF"),
|
||||||
|
rowComposite,
|
||||||
|
() => SelectReport(entry.SessionId),
|
||||||
|
IconVariant.Filled);
|
||||||
|
Grid.SetColumn(playButton, 1);
|
||||||
|
rowGrid.Children.Add(playButton);
|
||||||
|
|
||||||
|
var renameButton = CreateActionIconButton(
|
||||||
|
L("study.session_history.action.rename", "Rename"),
|
||||||
|
Symbol.Edit,
|
||||||
|
isLoading || isDialogOpen,
|
||||||
|
Color.Parse("#2BFFFFFF"),
|
||||||
|
rowComposite,
|
||||||
|
() => ShowRenameDialog(entry.SessionId, entry.Label));
|
||||||
|
Grid.SetColumn(renameButton, 2);
|
||||||
|
rowGrid.Children.Add(renameButton);
|
||||||
|
|
||||||
|
var deleteButton = CreateActionIconButton(
|
||||||
|
L("study.session_history.action.delete", "Delete"),
|
||||||
|
Symbol.Delete,
|
||||||
|
isLoading || isDialogOpen,
|
||||||
|
Color.Parse("#5AC74E58"),
|
||||||
|
rowComposite,
|
||||||
|
() => ShowDeleteDialog(entry.SessionId, entry.Label));
|
||||||
|
Grid.SetColumn(deleteButton, 3);
|
||||||
|
rowGrid.Children.Add(deleteButton);
|
||||||
|
|
||||||
|
rowBorder.Child = rowGrid;
|
||||||
|
return rowBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button CreateActionIconButton(
|
||||||
|
string tooltip,
|
||||||
|
Symbol symbol,
|
||||||
|
bool isDisabled,
|
||||||
|
Color buttonBackground,
|
||||||
|
Color rowComposite,
|
||||||
|
Action onClick,
|
||||||
|
IconVariant iconVariant = IconVariant.Regular)
|
||||||
|
{
|
||||||
|
var buttonComposite = ToOpaqueAgainst(buttonBackground, rowComposite);
|
||||||
|
var iconBrush = CreateAdaptiveBrush(new[] { buttonComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||||
|
|
||||||
|
var iconSize = Math.Clamp(13 * (_isCompactMode ? 0.92 : 1.0), 11, 17);
|
||||||
|
var icon = new SymbolIcon
|
||||||
|
{
|
||||||
|
Symbol = symbol,
|
||||||
|
IconVariant = iconVariant,
|
||||||
|
FontSize = iconSize,
|
||||||
|
Width = iconSize,
|
||||||
|
Height = iconSize,
|
||||||
|
Foreground = iconBrush,
|
||||||
|
IsHitTestVisible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var button = new Button
|
||||||
|
{
|
||||||
|
MinWidth = _isUltraCompactMode ? 26 : 34,
|
||||||
|
Width = _isUltraCompactMode ? 26 : 34,
|
||||||
|
Height = Math.Clamp(26 * (_isCompactMode ? 0.90 : 1.0), 24, 30),
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
CornerRadius = new CornerRadius(10),
|
||||||
|
Background = new SolidColorBrush(buttonBackground),
|
||||||
|
BorderBrush = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Content = icon,
|
||||||
|
IsEnabled = !isDisabled
|
||||||
|
};
|
||||||
|
button.Classes.Add("study-history-action-button");
|
||||||
|
ToolTip.SetTip(button, tooltip);
|
||||||
|
button.Click += (_, _) => onClick();
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectReport(string sessionId)
|
||||||
|
{
|
||||||
|
CloseDialog();
|
||||||
|
|
||||||
|
_loadingSessionId = sessionId;
|
||||||
|
SetTransientStatus(L("study.session_history.loading", "Loading data..."), 4);
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_studyAnalyticsService.SelectSessionReport(sessionId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadingSessionId = null;
|
||||||
|
SetTransientStatus(L("study.session_history.select_failed", "Unable to switch session"));
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowRenameDialog(string sessionId, string label)
|
||||||
|
{
|
||||||
|
_dialogMode = HistoryDialogMode.Rename;
|
||||||
|
_dialogSessionId = sessionId;
|
||||||
|
_dialogSessionLabel = label;
|
||||||
|
DialogRenameTextBox.Text = label;
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowDeleteDialog(string sessionId, string label)
|
||||||
|
{
|
||||||
|
_dialogMode = HistoryDialogMode.Delete;
|
||||||
|
_dialogSessionId = sessionId;
|
||||||
|
_dialogSessionLabel = label;
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmDialog()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_dialogSessionId))
|
||||||
|
{
|
||||||
|
CloseDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_dialogMode == HistoryDialogMode.Rename)
|
||||||
|
{
|
||||||
|
ConfirmRename(_dialogSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_dialogMode == HistoryDialogMode.Delete)
|
||||||
|
{
|
||||||
|
ConfirmDelete(_dialogSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmRename(string sessionId)
|
||||||
|
{
|
||||||
|
var nextLabel = (DialogRenameTextBox.Text ?? string.Empty).Trim();
|
||||||
|
if (!_studyAnalyticsService.RenameSessionReport(sessionId, nextLabel))
|
||||||
|
{
|
||||||
|
SetTransientStatus(L("study.session_history.rename_failed", "Unable to rename session"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmDelete(string sessionId)
|
||||||
|
{
|
||||||
|
if (!_studyAnalyticsService.DeleteSessionReport(sessionId))
|
||||||
|
{
|
||||||
|
SetTransientStatus(L("study.session_history.delete_failed", "Unable to delete session"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetTransientStatus(L("study.session_history.loaded", "Data loaded"), 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDialog()
|
||||||
|
{
|
||||||
|
_dialogMode = HistoryDialogMode.None;
|
||||||
|
_dialogSessionId = null;
|
||||||
|
_dialogSessionLabel = string.Empty;
|
||||||
|
DialogRenameTextBox.Text = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (_dialogMode != HistoryDialogMode.Rename)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
ConfirmDialog();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
CloseDialog();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDialogVisual(StudyAnalyticsSnapshot snapshot, Color panelColor)
|
||||||
|
{
|
||||||
|
var isVisible = _dialogMode != HistoryDialogMode.None && !string.IsNullOrWhiteSpace(_dialogSessionId);
|
||||||
|
DialogOverlayBorder.IsVisible = isVisible;
|
||||||
|
if (!isVisible)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialogBackground = Color.Parse("#D92A3E5D");
|
||||||
|
var dialogComposite = ToOpaqueAgainst(dialogBackground, ToOpaqueAgainst(panelColor, DarkSubstrate));
|
||||||
|
DialogCardBorder.Background = new SolidColorBrush(dialogBackground);
|
||||||
|
DialogCardBorder.BorderBrush = new SolidColorBrush(Color.Parse("#66FFFFFF"));
|
||||||
|
DialogTitleTextBlock.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||||
|
DialogMessageTextBlock.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, SecondaryColorCandidates, MinTextContrast);
|
||||||
|
DialogRenameTextBox.Foreground = CreateAdaptiveBrush(new[] { dialogComposite }, PrimaryColorCandidates, MinTextContrast);
|
||||||
|
DialogRenameTextBox.Background = new SolidColorBrush(Color.Parse("#24FFFFFF"));
|
||||||
|
DialogRenameTextBox.BorderBrush = new SolidColorBrush(Color.Parse("#52FFFFFF"));
|
||||||
|
|
||||||
|
var cancelBackground = Color.Parse("#33FFFFFF");
|
||||||
|
var confirmBackground = _dialogMode == HistoryDialogMode.Delete
|
||||||
|
? Color.Parse("#B7504D")
|
||||||
|
: Color.Parse("#4A73CC");
|
||||||
|
|
||||||
|
DialogCancelButton.Background = new SolidColorBrush(cancelBackground);
|
||||||
|
DialogCancelButton.BorderBrush = Brushes.Transparent;
|
||||||
|
DialogCancelButton.BorderThickness = new Thickness(0);
|
||||||
|
DialogCancelButton.Foreground = CreateAdaptiveBrush(
|
||||||
|
new[] { ToOpaqueAgainst(cancelBackground, dialogComposite) },
|
||||||
|
PrimaryColorCandidates,
|
||||||
|
MinTextContrast);
|
||||||
|
|
||||||
|
DialogConfirmButton.Background = new SolidColorBrush(confirmBackground);
|
||||||
|
DialogConfirmButton.BorderBrush = Brushes.Transparent;
|
||||||
|
DialogConfirmButton.BorderThickness = new Thickness(0);
|
||||||
|
DialogConfirmButton.Foreground = CreateAdaptiveBrush(
|
||||||
|
new[] { ToOpaqueAgainst(confirmBackground, dialogComposite) },
|
||||||
|
PrimaryColorCandidates,
|
||||||
|
MinTextContrast);
|
||||||
|
|
||||||
|
var entry = FindHistoryEntry(snapshot.SessionHistory, _dialogSessionId);
|
||||||
|
var label = entry?.Label ?? _dialogSessionLabel;
|
||||||
|
if (_dialogMode == HistoryDialogMode.Rename)
|
||||||
|
{
|
||||||
|
DialogTitleTextBlock.Text = L("study.session_history.dialog.rename_title", "Rename Session");
|
||||||
|
DialogMessageTextBlock.Text = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
L("study.session_history.dialog.rename_message", "Set a new name for \"{0}\"."),
|
||||||
|
label);
|
||||||
|
DialogRenameTextBox.Watermark = L("study.session_history.rename_placeholder", "Enter session name");
|
||||||
|
if (string.IsNullOrWhiteSpace(DialogRenameTextBox.Text))
|
||||||
|
{
|
||||||
|
DialogRenameTextBox.Text = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogRenameTextBox.IsVisible = true;
|
||||||
|
DialogConfirmButton.Content = L("study.session_history.rename_confirm", "Confirm rename");
|
||||||
|
DialogCancelButton.Content = L("study.session_history.rename_cancel", "Cancel rename");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DialogTitleTextBlock.Text = L("study.session_history.dialog.delete_title", "Delete Session");
|
||||||
|
DialogMessageTextBlock.Text = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
L("study.session_history.dialog.delete_message", "Delete \"{0}\"? This cannot be undone."),
|
||||||
|
label);
|
||||||
|
DialogRenameTextBox.IsVisible = false;
|
||||||
|
DialogConfirmButton.Content = L("study.session_history.dialog.delete_confirm", "Delete");
|
||||||
|
DialogCancelButton.Content = L("study.session_history.rename_cancel", "Cancel rename");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetTransientStatus(string status, double seconds = 2.2)
|
||||||
|
{
|
||||||
|
_transientStatus = status;
|
||||||
|
_transientStatusExpireAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(0.6, seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReloadLanguageCode()
|
||||||
|
{
|
||||||
|
var snapshot = _settingsService.Load();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAdaptiveLayout()
|
||||||
|
{
|
||||||
|
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.2);
|
||||||
|
var widthScale = Bounds.Width > 1 ? Bounds.Width / 360d : cellScale;
|
||||||
|
var heightScale = Bounds.Height > 1 ? Bounds.Height / 180d : cellScale;
|
||||||
|
var scale = Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.68, 2.2);
|
||||||
|
|
||||||
|
_isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 320) || (Bounds.Height > 1 && Bounds.Height < 145);
|
||||||
|
_isUltraCompactMode = scale < 0.78 || (Bounds.Width > 1 && Bounds.Width < 280) || (Bounds.Height > 1 && Bounds.Height < 120);
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 12, 36));
|
||||||
|
RootBorder.Padding = new Thickness(
|
||||||
|
Math.Clamp(12 * scale, 7, 22),
|
||||||
|
Math.Clamp(9 * scale, 5, 16));
|
||||||
|
|
||||||
|
ContentRootGrid.RowSpacing = _isUltraCompactMode
|
||||||
|
? Math.Clamp(4 * scale, 2, 6)
|
||||||
|
: Math.Clamp(7 * scale, 4, 10);
|
||||||
|
|
||||||
|
TitleTextBlock.FontSize = Math.Clamp(13 * scale, 10, 22);
|
||||||
|
StatusTextBlock.FontSize = Math.Clamp(11 * scale, 9, 18);
|
||||||
|
SessionListPanel.Spacing = _isUltraCompactMode
|
||||||
|
? Math.Clamp(4 * scale, 2, 5)
|
||||||
|
: Math.Clamp(6 * scale, 3, 8);
|
||||||
|
|
||||||
|
DialogOverlayBorder.Padding = new Thickness(
|
||||||
|
Math.Clamp(12 * scale, 8, 20),
|
||||||
|
Math.Clamp(10 * scale, 8, 18));
|
||||||
|
DialogCardBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 10, 18));
|
||||||
|
DialogCardBorder.Padding = new Thickness(
|
||||||
|
Math.Clamp(12 * scale, 9, 20),
|
||||||
|
Math.Clamp(11 * scale, 8, 18));
|
||||||
|
DialogTitleTextBlock.FontSize = Math.Clamp(14 * scale, 11, 20);
|
||||||
|
DialogMessageTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
|
||||||
|
DialogRenameTextBox.FontSize = Math.Clamp(11.5 * scale, 10, 16);
|
||||||
|
DialogCancelButton.FontSize = Math.Clamp(11 * scale, 10, 16);
|
||||||
|
DialogConfirmButton.FontSize = Math.Clamp(11 * scale, 10, 16);
|
||||||
|
DialogCancelButton.Height = Math.Clamp(30 * scale, 26, 38);
|
||||||
|
DialogConfirmButton.Height = Math.Clamp(30 * scale, 26, 38);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudySessionHistoryEntry? FindHistoryEntry(IReadOnlyList<StudySessionHistoryEntry> history, string? sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < history.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = history[i];
|
||||||
|
if (string.Equals(entry.SessionId, sessionId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatDuration(TimeSpan duration)
|
||||||
|
{
|
||||||
|
if (duration < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
duration = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration.TotalHours >= 1)
|
||||||
|
{
|
||||||
|
var totalHours = (int)Math.Floor(duration.TotalHours);
|
||||||
|
return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00}", totalHours, duration.Minutes, duration.Seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}", duration.Minutes, duration.Seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
return _localizationService.GetString(_languageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Color ResolvePanelBackgroundColor()
|
||||||
|
{
|
||||||
|
if (RootBorder.Background is ISolidColorBrush solidBackground)
|
||||||
|
{
|
||||||
|
return solidBackground.Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) &&
|
||||||
|
resource is ISolidColorBrush solidBrush)
|
||||||
|
{
|
||||||
|
return solidBrush.Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Color.Parse("#FF1E293B");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Color> BuildPanelBackgroundSamples(Color panelColor)
|
||||||
|
{
|
||||||
|
var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
|
||||||
|
var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
opaqueOnDark,
|
||||||
|
opaqueOnLight,
|
||||||
|
ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.22),
|
||||||
|
ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.14),
|
||||||
|
ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ToOpaqueAgainst(Color foreground, Color background)
|
||||||
|
{
|
||||||
|
if (foreground.A >= 0xFF)
|
||||||
|
{
|
||||||
|
return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B);
|
||||||
|
}
|
||||||
|
|
||||||
|
var alpha = foreground.A / 255d;
|
||||||
|
var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
|
||||||
|
var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
|
||||||
|
var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
|
||||||
|
return Color.FromArgb(0xFF, red, green, blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IBrush CreateAdaptiveBrush(IReadOnlyList<Color> backgroundSamples, IReadOnlyList<Color> candidates, double minContrast)
|
||||||
|
{
|
||||||
|
var selected = candidates[0];
|
||||||
|
var bestRatio = double.MinValue;
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
var ratio = MinContrastRatio(candidate, backgroundSamples);
|
||||||
|
if (ratio >= minContrast)
|
||||||
|
{
|
||||||
|
selected = candidate;
|
||||||
|
bestRatio = ratio;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratio > bestRatio)
|
||||||
|
{
|
||||||
|
bestRatio = ratio;
|
||||||
|
selected = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SolidColorBrush(Color.FromArgb(0xFF, selected.R, selected.G, selected.B));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MinContrastRatio(Color foreground, IReadOnlyList<Color> backgrounds)
|
||||||
|
{
|
||||||
|
var min = double.MaxValue;
|
||||||
|
for (var i = 0; i < backgrounds.Count; i++)
|
||||||
|
{
|
||||||
|
var ratio = ColorMath.ContrastRatio(foreground, backgrounds[i]);
|
||||||
|
if (ratio < min)
|
||||||
|
{
|
||||||
|
min = ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1479,14 +1479,15 @@ public partial class MainWindow
|
|||||||
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
|
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
|
||||||
{
|
{
|
||||||
host.IsHitTestVisible = true;
|
host.IsHitTestVisible = true;
|
||||||
|
var keepContentInteractive = ShouldKeepContentInteractiveInEditMode(host);
|
||||||
|
|
||||||
if (TryGetContentHost(host) is Border contentHost)
|
if (TryGetContentHost(host) is Border contentHost)
|
||||||
{
|
{
|
||||||
// In edit mode, prefer drag interactions over component interactions.
|
// In edit mode, keep selected interactive widgets usable; drag/resize still uses host border/handles.
|
||||||
contentHost.IsHitTestVisible = !isEditMode;
|
contentHost.IsHitTestVisible = !isEditMode || keepContentInteractive;
|
||||||
if (contentHost.Child is Control componentControl)
|
if (contentHost.Child is Control componentControl)
|
||||||
{
|
{
|
||||||
componentControl.IsHitTestVisible = !isEditMode;
|
componentControl.IsHitTestVisible = !isEditMode || keepContentInteractive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,6 +1495,27 @@ public partial class MainWindow
|
|||||||
ApplySelectionStateToHost(host, isSelected);
|
ApplySelectionStateToHost(host, isSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool ShouldKeepContentInteractiveInEditMode(Border host)
|
||||||
|
{
|
||||||
|
if (!_isComponentLibraryOpen ||
|
||||||
|
host.Tag is not string placementId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var placement = _desktopComponentPlacements.FirstOrDefault(p =>
|
||||||
|
string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (placement is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
placement.ComponentId,
|
||||||
|
BuiltInComponentIds.DesktopStudySessionHistory,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnDesktopComponentHostPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_isComponentLibraryOpen || _isDesktopComponentDragActive || _isDesktopComponentResizeActive)
|
if (!_isComponentLibraryOpen || _isDesktopComponentDragActive || _isDesktopComponentResizeActive)
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ public partial class MainWindow
|
|||||||
private double _desktopSurfacePageWidth;
|
private double _desktopSurfacePageWidth;
|
||||||
private TranslateTransform? _desktopPagesHostTransform;
|
private TranslateTransform? _desktopPagesHostTransform;
|
||||||
private bool _isDesktopSwipeActive;
|
private bool _isDesktopSwipeActive;
|
||||||
|
private bool _isDesktopSwipeDirectionLocked;
|
||||||
private Point _desktopSwipeStartPoint;
|
private Point _desktopSwipeStartPoint;
|
||||||
private Point _desktopSwipeCurrentPoint;
|
private Point _desktopSwipeCurrentPoint;
|
||||||
|
private Point _desktopSwipeLastPoint;
|
||||||
|
private long _desktopSwipeLastTimestamp;
|
||||||
|
private double _desktopSwipeVelocityX;
|
||||||
private double _desktopSwipeBaseOffset;
|
private double _desktopSwipeBaseOffset;
|
||||||
|
|
||||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||||
@@ -48,6 +52,15 @@ public partial class MainWindow
|
|||||||
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeDesktopSurfaceSwipeHandlers()
|
||||||
|
{
|
||||||
|
// Capture swipe intent before child controls consume pointer events.
|
||||||
|
AddHandler(PointerPressedEvent, OnDesktopPagesPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||||
|
AddHandler(PointerMovedEvent, OnDesktopPagesPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||||
|
AddHandler(PointerReleasedEvent, OnDesktopPagesPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||||
|
AddHandler(PointerCaptureLostEvent, OnDesktopPagesPointerCaptureLost, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||||
|
}
|
||||||
|
|
||||||
private async void LoadLauncherEntriesAsync()
|
private async void LoadLauncherEntriesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -292,7 +305,12 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var target = Math.Clamp(_currentDesktopSurfaceIndex + delta, 0, LauncherSurfaceIndex);
|
MoveSurfaceTo(_currentDesktopSurfaceIndex + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveSurfaceTo(int targetIndex)
|
||||||
|
{
|
||||||
|
var target = Math.Clamp(targetIndex, 0, LauncherSurfaceIndex);
|
||||||
if (target == _currentDesktopSurfaceIndex)
|
if (target == _currentDesktopSurfaceIndex)
|
||||||
{
|
{
|
||||||
ApplyDesktopSurfaceOffset();
|
ApplyDesktopSurfaceOffset();
|
||||||
@@ -306,12 +324,16 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private bool CanSwipeDesktopSurface()
|
private bool CanSwipeDesktopSurface()
|
||||||
{
|
{
|
||||||
return !_isSettingsOpen && !_isDesktopComponentDragActive && _desktopSurfacePageWidth > 1;
|
return !_isSettingsOpen &&
|
||||||
|
!_isComponentLibraryOpen &&
|
||||||
|
!_isDesktopComponentDragActive &&
|
||||||
|
!_isDesktopComponentResizeActive &&
|
||||||
|
_desktopSurfacePageWidth > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDesktopPagesPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnDesktopPagesPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DesktopPagesViewport is null)
|
if (!TryGetPointerPositionInDesktopViewport(e, out var pointerInViewport))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -336,16 +358,24 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsDesktopSwipeBlockedPointerSource(e.Source))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
if (!e.GetCurrentPoint(DesktopPagesViewport).Properties.IsLeftButtonPressed)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isDesktopSwipeActive = true;
|
_isDesktopSwipeActive = true;
|
||||||
_desktopSwipeStartPoint = e.GetPosition(DesktopPagesViewport);
|
_isDesktopSwipeDirectionLocked = false;
|
||||||
|
_desktopSwipeStartPoint = pointerInViewport;
|
||||||
_desktopSwipeCurrentPoint = _desktopSwipeStartPoint;
|
_desktopSwipeCurrentPoint = _desktopSwipeStartPoint;
|
||||||
|
_desktopSwipeLastPoint = _desktopSwipeStartPoint;
|
||||||
|
_desktopSwipeVelocityX = 0;
|
||||||
|
_desktopSwipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||||
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||||
e.Pointer.Capture(DesktopPagesViewport);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsInteractivePointerSource(object? source)
|
private static bool IsInteractivePointerSource(object? source)
|
||||||
@@ -376,24 +406,172 @@ public partial class MainWindow
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsDesktopSwipeBlockedPointerSource(object? source)
|
||||||
|
{
|
||||||
|
if (source is not Visual visual)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingNodes = new Stack<object>();
|
||||||
|
var visitedNodes = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||||
|
pendingNodes.Push(visual);
|
||||||
|
|
||||||
|
while (pendingNodes.Count > 0)
|
||||||
|
{
|
||||||
|
var node = pendingNodes.Pop();
|
||||||
|
if (!visitedNodes.Add(node))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsDesktopSwipeBlockingNode(node))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is StyledElement styledElement &&
|
||||||
|
styledElement.TemplatedParent is { } templatedParent)
|
||||||
|
{
|
||||||
|
pendingNodes.Push(templatedParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is Visual currentVisual &&
|
||||||
|
currentVisual.GetVisualParent() is { } parentVisual)
|
||||||
|
{
|
||||||
|
pendingNodes.Push(parentVisual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDesktopSwipeBlockingNode(object node)
|
||||||
|
{
|
||||||
|
if (node is Button or TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem or ScrollViewer)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is Control control &&
|
||||||
|
(control.Classes.Contains("study-history-action-button") ||
|
||||||
|
control.Classes.Contains("desktop-component") ||
|
||||||
|
control.Classes.Contains("desktop-component-host")))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeName = node.GetType().Name;
|
||||||
|
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||||
|
{
|
||||||
|
point = default;
|
||||||
|
if (DesktopPagesViewport is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
point = e.GetPosition(DesktopPagesViewport);
|
||||||
|
if (_isDesktopSwipeActive && _isDesktopSwipeDirectionLocked)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bounds = DesktopPagesViewport.Bounds;
|
||||||
|
return bounds.Width > 1 &&
|
||||||
|
bounds.Height > 1 &&
|
||||||
|
point.X >= 0 &&
|
||||||
|
point.Y >= 0 &&
|
||||||
|
point.X <= bounds.Width &&
|
||||||
|
point.Y <= bounds.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDesktopSwipeVelocity(Point pointer)
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
if (_desktopSwipeLastTimestamp > 0)
|
||||||
|
{
|
||||||
|
var elapsedSeconds = (now - _desktopSwipeLastTimestamp) / (double)Stopwatch.Frequency;
|
||||||
|
if (elapsedSeconds > 0.0001)
|
||||||
|
{
|
||||||
|
var instantVelocity = (pointer.X - _desktopSwipeLastPoint.X) / elapsedSeconds;
|
||||||
|
_desktopSwipeVelocityX = _desktopSwipeVelocityX * 0.7 + instantVelocity * 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopSwipeLastPoint = pointer;
|
||||||
|
_desktopSwipeLastTimestamp = now;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDesktopPagesPointerMoved(object? sender, PointerEventArgs e)
|
private void OnDesktopPagesPointerMoved(object? sender, PointerEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_isDesktopSwipeActive || DesktopPagesViewport is null || _desktopPagesHostTransform is null)
|
if (!_isDesktopSwipeActive || !TryGetPointerPositionInDesktopViewport(e, out var pointerInViewport))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_desktopSwipeCurrentPoint = e.GetPosition(DesktopPagesViewport);
|
if (_desktopPagesHostTransform is null || DesktopPagesViewport is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopSwipeCurrentPoint = pointerInViewport;
|
||||||
|
UpdateDesktopSwipeVelocity(pointerInViewport);
|
||||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||||
|
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
||||||
|
|
||||||
|
if (!_isDesktopSwipeDirectionLocked)
|
||||||
|
{
|
||||||
|
const double activationThreshold = 14;
|
||||||
|
const double horizontalBias = 1.15;
|
||||||
|
var absDeltaX = Math.Abs(deltaX);
|
||||||
|
var absDeltaY = Math.Abs(deltaY);
|
||||||
|
|
||||||
|
if (absDeltaY >= activationThreshold && absDeltaY > absDeltaX * horizontalBias)
|
||||||
|
{
|
||||||
|
CancelDesktopSwipeInteraction(e.Pointer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absDeltaX < activationThreshold || absDeltaX <= absDeltaY * horizontalBias)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDesktopSwipeDirectionLocked = true;
|
||||||
|
if (e.Pointer.Captured != DesktopPagesViewport)
|
||||||
|
{
|
||||||
|
e.Pointer.Capture(DesktopPagesViewport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var minOffset = -LauncherSurfaceIndex * _desktopSurfacePageWidth;
|
var minOffset = -LauncherSurfaceIndex * _desktopSurfacePageWidth;
|
||||||
var tentative = _desktopSwipeBaseOffset + deltaX;
|
var tentative = _desktopSwipeBaseOffset + deltaX;
|
||||||
_desktopPagesHostTransform.X = Math.Clamp(tentative, minOffset, 0);
|
if (tentative > 0)
|
||||||
|
{
|
||||||
|
tentative *= 0.24;
|
||||||
|
}
|
||||||
|
else if (tentative < minOffset)
|
||||||
|
{
|
||||||
|
tentative = minOffset + (tentative - minOffset) * 0.24;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopPagesHostTransform.X = tentative;
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
private void OnDesktopPagesPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
{
|
{
|
||||||
EndDesktopSwipeInteraction(e.Pointer);
|
if (EndDesktopSwipeInteraction(e.Pointer))
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
private void OnDesktopPagesPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||||
@@ -401,29 +579,83 @@ public partial class MainWindow
|
|||||||
EndDesktopSwipeInteraction(e.Pointer);
|
EndDesktopSwipeInteraction(e.Pointer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EndDesktopSwipeInteraction(IPointer? pointer)
|
private void CancelDesktopSwipeInteraction(IPointer? pointer)
|
||||||
{
|
{
|
||||||
if (!_isDesktopSwipeActive)
|
if (!_isDesktopSwipeActive)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isDesktopSwipeActive = false;
|
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||||
if (pointer?.Captured == DesktopPagesViewport)
|
if (pointer?.Captured == DesktopPagesViewport)
|
||||||
{
|
{
|
||||||
pointer.Capture(null);
|
pointer.Capture(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isDesktopSwipeActive = false;
|
||||||
|
_isDesktopSwipeDirectionLocked = false;
|
||||||
|
_desktopSwipeVelocityX = 0;
|
||||||
|
_desktopSwipeLastTimestamp = 0;
|
||||||
|
if (wasDirectionLocked)
|
||||||
|
{
|
||||||
|
ApplyDesktopSurfaceOffset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EndDesktopSwipeInteraction(IPointer? pointer)
|
||||||
|
{
|
||||||
|
if (!_isDesktopSwipeActive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasDirectionLocked = _isDesktopSwipeDirectionLocked;
|
||||||
|
_isDesktopSwipeActive = false;
|
||||||
|
_isDesktopSwipeDirectionLocked = false;
|
||||||
|
if (pointer?.Captured == DesktopPagesViewport)
|
||||||
|
{
|
||||||
|
pointer.Capture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopSwipeLastTimestamp = 0;
|
||||||
|
if (!wasDirectionLocked)
|
||||||
|
{
|
||||||
|
_desktopSwipeVelocityX = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||||
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
||||||
var threshold = Math.Max(56, _desktopSurfacePageWidth * 0.16);
|
var absDeltaX = Math.Abs(deltaX);
|
||||||
if (Math.Abs(deltaX) >= threshold && Math.Abs(deltaX) > Math.Abs(deltaY))
|
var absDeltaY = Math.Abs(deltaY);
|
||||||
|
var distanceThreshold = Math.Max(48, _desktopSurfacePageWidth * 0.14);
|
||||||
|
var velocityThreshold = Math.Max(860, _desktopSurfacePageWidth * 1.08);
|
||||||
|
var predictedDeltaX = deltaX + _desktopSwipeVelocityX * 0.18;
|
||||||
|
var predictedOffset = _desktopSwipeBaseOffset + predictedDeltaX;
|
||||||
|
var projectedTargetIndex = (int)Math.Round(-predictedOffset / _desktopSurfacePageWidth);
|
||||||
|
projectedTargetIndex = Math.Clamp(projectedTargetIndex, 0, LauncherSurfaceIndex);
|
||||||
|
|
||||||
|
var hasDistanceIntent = absDeltaX >= distanceThreshold && absDeltaX > absDeltaY * 1.05;
|
||||||
|
var hasVelocityIntent = Math.Abs(_desktopSwipeVelocityX) >= velocityThreshold;
|
||||||
|
|
||||||
|
if (projectedTargetIndex == _currentDesktopSurfaceIndex && (hasDistanceIntent || hasVelocityIntent))
|
||||||
{
|
{
|
||||||
MoveSurfaceBy(deltaX < 0 ? 1 : -1);
|
projectedTargetIndex = Math.Clamp(
|
||||||
return;
|
_currentDesktopSurfaceIndex + (deltaX < 0 ? 1 : -1),
|
||||||
|
0,
|
||||||
|
LauncherSurfaceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopSwipeVelocityX = 0;
|
||||||
|
|
||||||
|
if (projectedTargetIndex != _currentDesktopSurfaceIndex)
|
||||||
|
{
|
||||||
|
MoveSurfaceTo(projectedTargetIndex);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyDesktopSurfaceOffset();
|
ApplyDesktopSurfaceOffset();
|
||||||
|
return hasDistanceIntent || hasVelocityIntent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDesktopPagesPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
private void OnDesktopPagesPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||||
|
|||||||
@@ -100,10 +100,6 @@
|
|||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
ClipToBounds="True"
|
ClipToBounds="True"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
PointerPressed="OnDesktopPagesPointerPressed"
|
|
||||||
PointerMoved="OnDesktopPagesPointerMoved"
|
|
||||||
PointerReleased="OnDesktopPagesPointerReleased"
|
|
||||||
PointerCaptureLost="OnDesktopPagesPointerCaptureLost"
|
|
||||||
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
|
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid x:Name="DesktopPagesHost"
|
<Grid x:Name="DesktopPagesHost"
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ public partial class MainWindow : Window
|
|||||||
_componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry);
|
_componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry);
|
||||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||||
PropertyChanged += OnWindowPropertyChanged;
|
PropertyChanged += OnWindowPropertyChanged;
|
||||||
|
InitializeDesktopSurfaceSwipeHandlers();
|
||||||
InitializeDesktopComponentDragHandlers();
|
InitializeDesktopComponentDragHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user