Skip to content

Commit 632e7a6

Browse files
authored
Merge pull request #6 from lastowl/feature/file-based-schedules
File-based schedule picker, side-by-side layout, display mode fixes
2 parents 79b70df + 943b8cb commit 632e7a6

14 files changed

Lines changed: 534 additions & 105 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using System.Globalization;
3+
using Avalonia;
4+
using Avalonia.Controls;
5+
using Avalonia.Data.Converters;
6+
7+
namespace OnlyT.Avalonia.Converters;
8+
9+
public class PercentToGridLengthConverter : IValueConverter
10+
{
11+
public static readonly PercentToGridLengthConverter Instance = new();
12+
public static readonly PercentToGridLengthConverter ComplementInstance = new() { IsComplement = true };
13+
14+
public bool IsComplement { get; set; }
15+
16+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
17+
{
18+
if (value is int percent)
19+
{
20+
var v = IsComplement ? (100 - percent) : percent;
21+
return new GridLength(Math.Max(v, 1), GridUnitType.Star);
22+
}
23+
24+
return new GridLength(1, GridUnitType.Star);
25+
}
26+
27+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
28+
{
29+
throw new NotImplementedException();
30+
}
31+
}

OnlyT.Avalonia/Properties/Resources.resx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,30 @@ Shift - Increase 5m</value>
825825
<value>Min</value>
826826
<comment>Watermark/placeholder for the quick-set minutes input in manual mode</comment>
827827
</data>
828+
<data name="BTN_SAVE_TEMPLATE" xml:space="preserve">
829+
<value>Save</value>
830+
<comment>Button to export current schedule as a reusable template file</comment>
831+
</data>
832+
<data name="TIP_EXPORT_SCHEDULE" xml:space="preserve">
833+
<value>Save current schedule as a reusable template</value>
834+
<comment>Tooltip for the save-template button in file-based mode</comment>
835+
</data>
836+
<data name="SCHEDULE_FILE" xml:space="preserve">
837+
<value>Schedule file</value>
838+
<comment>Label for the schedule template file selector in settings</comment>
839+
</data>
840+
<data name="SHOW_EXPORT_SCHEDULE" xml:space="preserve">
841+
<value>Show export schedule button</value>
842+
<comment>Checkbox in Settings > Misc to show/hide the schedule export icon on operator page</comment>
843+
</data>
844+
<data name="SPLIT_WIDTH_TIP" xml:space="preserve">
845+
<value>Controls the analogue clock size. Only applies in horizontal layout with an analogue clock.</value>
846+
<comment>Tooltip for the analogue clock width slider</comment>
847+
</data>
848+
<data name="HORIZONTAL_CLOCK_LAYOUT" xml:space="preserve">
849+
<value>Side-by-side clock layout (clock left, timer right)</value>
850+
<comment>Checkbox in Timer Window section to switch between horizontal and vertical clock layouts</comment>
851+
</data>
828852
<data name="MENU_PLUS_15_SEC" xml:space="preserve">
829853
<value>+15 seconds</value>
830854
<comment>Context menu item for time adjustment</comment>

OnlyT.Avalonia/Services/IOptionsService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,17 @@ public class AppOptions
392392
/// </summary>
393393
public bool ClassicMode { get; set; } = false;
394394

395+
/// <summary>
396+
/// Filename (not full path) of the selected schedule template within
397+
/// the Schedules folder. When empty, the legacy talk_schedule.xml is
398+
/// used for backward compatibility.
399+
/// </summary>
400+
public string SelectedScheduleFile { get; set; } = string.Empty;
401+
402+
public bool ShowExportScheduleButton { get; set; } = false;
403+
404+
public bool HorizontalClockLayout { get; set; } = false;
405+
395406
/// <summary>
396407
/// Adaptive timer mode for midweek meetings (default: None)
397408
/// </summary>

OnlyT.Avalonia/Services/SimpleOptionsService.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,23 @@ public void SaveOptions(AppOptions options)
212212

213213
Log.Debug("Options saved successfully");
214214

215-
try
216-
{
217-
OptionsChanged?.Invoke(this, EventArgs.Empty);
218-
}
219-
catch (Exception ex)
215+
// Invoke each subscriber individually so one failing handler
216+
// doesn't prevent subsequent handlers from running. Previously
217+
// a single Invoke + catch swallowed the first exception and
218+
// silently skipped all remaining subscribers.
219+
if (OptionsChanged != null)
220220
{
221-
Log.Warning(ex, "OptionsChanged subscriber threw");
221+
foreach (var handler in OptionsChanged.GetInvocationList())
222+
{
223+
try
224+
{
225+
((EventHandler)handler)(this, EventArgs.Empty);
226+
}
227+
catch (Exception ex)
228+
{
229+
Log.Warning(ex, "OptionsChanged subscriber threw");
230+
}
231+
}
222232
}
223233
}
224234
catch (UnauthorizedAccessException ex)

OnlyT.Avalonia/Services/TalkSchedule/TalkScheduleFileBased.cs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ internal static class TalkScheduleFileBased
1616
{
1717
private static readonly int StartId = 5000;
1818

19-
public static List<TalkScheduleItem> Read(bool autoBell)
19+
public static List<TalkScheduleItem> Read(bool autoBell, string? selectedFile = null)
2020
{
2121
var result = new List<TalkScheduleItem>();
22+
var path = ResolvePath(selectedFile);
2223

23-
if (Exists())
24+
if (File.Exists(path))
2425
{
25-
var path = GetFullPath();
2626
try
2727
{
2828
var x = XDocument.Load(path);
@@ -62,22 +62,32 @@ public static List<TalkScheduleItem> Read(bool autoBell)
6262
}
6363
else
6464
{
65-
Log.Information("Talk schedule file not found: {Path}", GetFullPath());
65+
Log.Information("Talk schedule file not found: {Path}", path);
6666
}
6767

6868
return result;
6969
}
7070

71-
private static string GetFullPath()
71+
/// <summary>
72+
/// Resolves the schedule file path. If a selected template filename is
73+
/// provided, it is looked up in the Schedules folder. Otherwise falls
74+
/// back to the legacy talk_schedule.xml for backward compatibility.
75+
/// </summary>
76+
private static string ResolvePath(string? selectedFile)
7277
{
78+
if (!string.IsNullOrEmpty(selectedFile))
79+
{
80+
var templatePath = System.IO.Path.Combine(
81+
FileUtils.GetScheduleTemplatesFolder(), selectedFile);
82+
if (File.Exists(templatePath))
83+
{
84+
return templatePath;
85+
}
86+
Log.Warning("Selected schedule template not found: {File}, falling back to default", selectedFile);
87+
}
7388
return FileUtils.GetTalkSchedulePath();
7489
}
7590

76-
private static bool Exists()
77-
{
78-
return File.Exists(GetFullPath());
79-
}
80-
8191
private static bool? AttributeToNullableBool(XAttribute? attribute, bool? defaultValue)
8292
{
8393
return string.IsNullOrEmpty(attribute?.Value)

OnlyT.Avalonia/Services/TalkSchedule/TalkScheduleService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ public TalkScheduleService(IOptionsService optionsService)
2525

2626
public void Reset()
2727
{
28+
var selectedFile = _optionsService.GetOptions().SelectedScheduleFile;
2829
_fileBasedSchedule = new Lazy<IEnumerable<TalkScheduleItem>>(() =>
29-
TalkScheduleFileBased.Read(_optionsService.AutoBell));
30+
TalkScheduleFileBased.Read(_optionsService.AutoBell, selectedFile));
3031
_autoSchedule = new Lazy<IEnumerable<TalkScheduleItem>>(() =>
3132
TalkScheduleAuto.Read(_optionsService));
3233
_manualSchedule = new Lazy<IEnumerable<TalkScheduleItem>>(() =>

OnlyT.Avalonia/Utils/FileUtils.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ public static string GetTalkSchedulePath()
9191
return Path.Combine(GetDocumentsFolder(), "talk_schedule.xml");
9292
}
9393

94+
public static string GetScheduleTemplatesFolder()
95+
{
96+
var folder = Path.Combine(GetDocumentsFolder(), "Schedules");
97+
Directory.CreateDirectory(folder);
98+
return folder;
99+
}
100+
94101
public static string GetTimingReportsFolder(string? identifier = null)
95102
{
96103
var folder = Path.Combine(GetDocumentsFolder(), "TimingReports", identifier ?? string.Empty);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.Collections.Generic;
2+
using System.IO;
3+
using System.Xml.Linq;
4+
using OnlyT.Avalonia.Models;
5+
using Serilog;
6+
7+
namespace OnlyT.Avalonia.Utils;
8+
9+
/// <summary>
10+
/// Serializes a talk schedule to the file-based XML format so that the
11+
/// current Automatic/Manual schedule can be saved as a reusable template.
12+
/// </summary>
13+
public static class ScheduleExporter
14+
{
15+
public static void Export(IEnumerable<TalkScheduleItem> talks, string filePath)
16+
{
17+
var items = new XElement("items");
18+
19+
foreach (var talk in talks)
20+
{
21+
var item = new XElement("item",
22+
new XAttribute("name", talk.Name ?? string.Empty),
23+
new XAttribute("duration", talk.OriginalDuration.ToString(@"hh\:mm\:ss")),
24+
new XAttribute("editable", talk.Editable.ToString().ToLower()),
25+
new XAttribute("bell", talk.BellApplicable.ToString().ToLower()));
26+
27+
if (!string.IsNullOrEmpty(talk.MeetingSectionNameInternal))
28+
{
29+
item.Add(new XAttribute("section", talk.MeetingSectionNameInternal));
30+
}
31+
32+
if (talk.CountUp.HasValue)
33+
{
34+
item.Add(new XAttribute("countup", talk.CountUp.Value.ToString().ToLower()));
35+
}
36+
37+
if (talk.PersistFinalTimerValue)
38+
{
39+
item.Add(new XAttribute("persist", "true"));
40+
}
41+
42+
if (talk.ClosingSecs != 30)
43+
{
44+
item.Add(new XAttribute("closing", talk.ClosingSecs));
45+
}
46+
47+
items.Add(item);
48+
}
49+
50+
var doc = new XDocument(new XElement("meeting", items));
51+
doc.Save(filePath);
52+
53+
Log.Information("Exported schedule template to {Path} ({Count} talks)",
54+
filePath, items.Elements().Count());
55+
}
56+
57+
public static string[] GetAvailableTemplates()
58+
{
59+
var folder = FileUtils.GetScheduleTemplatesFolder();
60+
if (!Directory.Exists(folder))
61+
{
62+
return [];
63+
}
64+
65+
return Directory.GetFiles(folder, "*.xml");
66+
}
67+
}

OnlyT.Avalonia/ViewModels/OperatorPageViewModel.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ private void OnOptionsChangedExternally()
148148
OnPropertyChanged(nameof(ShouldShowCircuitVisitToggle));
149149
OnPropertyChanged(nameof(AllowCountUpDownToggle));
150150
OnPropertyChanged(nameof(ShowUpDownButton));
151+
OnPropertyChanged(nameof(ShowExportScheduleButton));
151152

152153
// If OperatingMode or MidWeekOrWeekend changed, the entire talk
153154
// schedule needs rebuilding (different mode = different talk list).
@@ -164,6 +165,13 @@ private void OnOptionsChangedExternally()
164165
RefreshTalks();
165166
}
166167

168+
// Directly refresh the timer output VM so display-mode, clock
169+
// format, and all visual settings update on the output window.
170+
// This bypasses the OptionsChanged event subscription (which
171+
// was unreliable) and ensures the refresh runs on the UI thread.
172+
var timerOutputViewModel = CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetService<TimerOutputViewModel>();
173+
timerOutputViewModel?.RefreshSettings();
174+
167175
// Apply AlwaysOnTop + FullScreenMode + MonitorId to the output window
168176
ApplyWindowStateOptionsLive();
169177
});
@@ -323,6 +331,8 @@ public string BellTooltip
323331
public bool IsManualMode => _optionsService.OperatingMode == OperatingMode.Manual;
324332
public bool IsNotManualMode => !IsManualMode;
325333
public bool IsAutoMode => _optionsService.OperatingMode == OperatingMode.Automatic;
334+
public bool IsFileBasedMode => _optionsService.OperatingMode == OperatingMode.ScheduleFile;
335+
public bool ShowExportScheduleButton => _optionsService.GetOptions().ShowExportScheduleButton;
326336

327337
// Circuit visit
328338
public bool IsCircuitVisit
@@ -579,6 +589,13 @@ public void RefreshTalks()
579589
OnPropertyChanged(nameof(IsManualMode));
580590
OnPropertyChanged(nameof(IsNotManualMode));
581591
OnPropertyChanged(nameof(IsAutoMode));
592+
OnPropertyChanged(nameof(IsFileBasedMode));
593+
594+
// Refresh the schedule-file picker (may have new templates)
595+
if (IsFileBasedMode)
596+
{
597+
RefreshScheduleFiles();
598+
}
582599

583600
// Refresh timer output settings (e.g., analog/digital clock switch)
584601
var timerOutputViewModel = CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetService<TimerOutputViewModel>();
@@ -1222,6 +1239,89 @@ private void SkipTalk()
12221239

12231240
private bool CanSkipTalk() => !IsRunning && !IsPaused && SelectedTalk != null;
12241241

1242+
// --- File-based schedule picker + export ---
1243+
1244+
[ObservableProperty]
1245+
private ObservableCollection<string> _scheduleFiles = [];
1246+
1247+
[ObservableProperty]
1248+
private string? _selectedScheduleFile;
1249+
1250+
partial void OnSelectedScheduleFileChanged(string? value)
1251+
{
1252+
if (value == null) return;
1253+
var options = _optionsService.GetOptions();
1254+
options.SelectedScheduleFile = value;
1255+
_optionsService.SaveOptions(options);
1256+
RefreshTalks();
1257+
}
1258+
1259+
public void RefreshScheduleFiles()
1260+
{
1261+
var files = Utils.ScheduleExporter.GetAvailableTemplates();
1262+
1263+
// First-use seeding: if there are no templates yet and we have
1264+
// talks loaded (from a previous Auto/Manual session), export
1265+
// the current schedule as a starting template so the user has
1266+
// something to work with immediately.
1267+
if (files.Length == 0 && Talks.Count > 0)
1268+
{
1269+
var seedPath = System.IO.Path.Combine(
1270+
Utils.FileUtils.GetScheduleTemplatesFolder(), "default.xml");
1271+
Utils.ScheduleExporter.Export(Talks, seedPath);
1272+
files = Utils.ScheduleExporter.GetAvailableTemplates();
1273+
}
1274+
1275+
ScheduleFiles.Clear();
1276+
foreach (var f in files)
1277+
{
1278+
ScheduleFiles.Add(System.IO.Path.GetFileName(f));
1279+
}
1280+
1281+
var current = _optionsService.GetOptions().SelectedScheduleFile;
1282+
if (!string.IsNullOrEmpty(current) && ScheduleFiles.Contains(current))
1283+
{
1284+
SelectedScheduleFile = current;
1285+
}
1286+
else if (ScheduleFiles.Count > 0)
1287+
{
1288+
SelectedScheduleFile = ScheduleFiles[0];
1289+
}
1290+
1291+
OnPropertyChanged(nameof(IsFileBasedMode));
1292+
}
1293+
1294+
[RelayCommand]
1295+
private void ExportScheduleAsTemplate()
1296+
{
1297+
var folder = Utils.FileUtils.GetScheduleTemplatesFolder();
1298+
var talks = Talks;
1299+
1300+
if (talks.Count == 0)
1301+
{
1302+
StatusText = "No talks to export";
1303+
return;
1304+
}
1305+
1306+
// Generate a unique filename based on the current mode/meeting type
1307+
var prefix = _optionsService.OperatingMode switch
1308+
{
1309+
OperatingMode.Automatic => _optionsService.MidWeekOrWeekend == MidWeekOrWeekend.MidWeek ? "midweek" : "weekend",
1310+
OperatingMode.Manual => "manual",
1311+
OperatingMode.ScheduleFile => "custom",
1312+
_ => "schedule"
1313+
};
1314+
1315+
var timestamp = System.DateTime.Now.ToString("yyyyMMdd-HHmmss");
1316+
var filename = $"{prefix}-{timestamp}.xml";
1317+
var path = System.IO.Path.Combine(folder, filename);
1318+
1319+
Utils.ScheduleExporter.Export(talks, path);
1320+
StatusText = $"Saved: {filename}";
1321+
1322+
RefreshScheduleFiles();
1323+
}
1324+
12251325
// Quick-set: user types minutes in manual mode and presses Enter.
12261326
[ObservableProperty]
12271327
private decimal _quickSetMinutes = 5;

0 commit comments

Comments
 (0)