Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ It's a **Clean Architecture** solution with two front-ends over one shared core.
- `Trackify.Cli` — a **Spectre.Console.Cli** console app to deploy on a Linux server / Raspberry Pi (plain `net10.0` → BlueZ via `AddLinuxLego`'s runtime check). On a Windows dev box it additionally targets `net10.0-windows10.0.19041.0`, where the `WINDOWS` symbol makes Application compile in `AddWindowsLego` (WinRT) — so `discover`/`drive` work on Windows too (run that TFM; the plain `net10.0` flavor has no Windows transport). `RuntimeIdentifiers=linux-arm64` (on the net10.0 flavor) so arm64 restore/publish works from Rider.
- `Trackify.Tests` — xUnit (pure-logic + store round-trip).

- .NET 10, Uno.Sdk `6.5.36` (pinned in `global.json`; SDK pinned to `9.0.100` + `rollForward: latestMajor` → uses the newest installed major, net10 heads require the .NET 10 SDK). Update Uno in `global.json`, not in package props.
- .NET 10, Uno.Sdk `6.5.36` (pinned in `global.json`; SDK pinned to `10.0.0` + `rollForward: latestMajor` → uses the newest installed major, net10 heads require the .NET 10 SDK). Update Uno in `global.json`, not in package props.
- **CI** (GitHub Actions, `.github/workflows/`): `ci.yml` is the pre-merge gate on PRs to `master` — an ubuntu job builds the CLI + shared core and runs the tests. The Uno app is deliberately not built in CI (its 5 heads need workloads + macOS/Windows even with `-f <head>`, because restore imports workloads for all TFMs); the Android head is covered by `android-apk.yml`. `android-apk.yml` builds the Android APK on windows-latest (JDK 17 + `android` workload, `-f net10.0-android`) on tag `v*` or manual dispatch; artifact `trackify-apk`. Both provision the .NET 8/9/10 SDKs.
- Uno heads: `net10.0-android`, `net10.0-ios`, `net10.0-browserwasm`, `net10.0-desktop`, `net10.0-windows10.0.19041.0`. The shared libs + CLI are plain `net10.0`.
- Central Package Management (`Directory.Packages.props`) — add versions there, reference without a version in the csproj.
Expand Down Expand Up @@ -89,7 +89,7 @@ CLI deployment (Pi publish — no build flags; `trains.json`/store schema, syste
- **Type-name suffix per folder:** `Services/` → `*Service`, `Presentation/ViewModels/` → `*ViewModel`, `Presentation/Behaviors/` → `*Behavior`, `Presentation/Widgets/` → `*Widget`.
- **Folder is `Pages`, not "Screens".**
- **`Components/`** = page-specific composed sections that inherit the page's `DataContext`. **`Widgets/`** = reusable atoms that expose `DependencyProperty`s (e.g. `SpeedProfileWidget.Graph`).
- **No page code-behind** beyond `InitializeComponent()`, except the deliberate responsive master-detail layout in `Pages/MainPage.xaml.cs`. Use attached behaviors (e.g. `Behaviors/TappedCommandBehavior`) instead of `*_Tapped` handlers.
- **No code-behind** beyond `InitializeComponent()` — for Pages *and* Components/Widgets alike — except the deliberate responsive master-detail layout in `Pages/MainPage.xaml.cs` and `Pages/SecondPage.xaml.cs`: both react to width *and* a selection change (`SelectedTrain`/`SelectedSegment`), which `AdaptiveTrigger`/`VisualStateManager` can't express declaratively, so it's hand-rolled in a `SizeChanged`/`Loaded`/`DataContextChanged` handler — nothing else lives in either file. Any other view-side interactivity goes through a plain `Command` binding, or — only when an event needs to reach a command that isn't natively `Command`-bindable (e.g. a tapped `Grid`/`StackPanel`) — an attached behavior in `Presentation/Behaviors/` (`TappedCommandBehavior`). Avoid imperative control APIs (`ScrollViewer.ChangeView` and similar) entirely where possible — prefer a bound value the ViewModel owns (e.g. the Streckenplaner's zoom is a `SecondViewModel.ZoomFactor` double driving a `Viewbox`'s bound `Width`/`Height`, not a `ScrollViewer` zoom call) — both because it's more MVVM-honest and because `ScrollViewer`'s programmatic zoom isn't reliably supported across every Uno target. Reach for a new attached behavior rather than a `*_Click`/`*_Tapped` handler in a `.xaml.cs` file.
- **MVVM** with CommunityToolkit.Mvvm: field-based `[ObservableProperty]` and `[RelayCommand]`. (`MVVMTK0045` partial-property advice is intentionally suppressed.)
- Converters are registered **once** globally in `Styles/Converters.xaml` (merged in `App.xaml`); don't re-declare per page. Design tokens/styles live in `Styles/DesignTokens.xaml`.
- Classic `{Binding}` views carry a design-time `d:DataContext="{d:DesignInstance ...}"` (with `mc:Ignorable="d"`) so binding paths resolve in the IDE. This is design-time only. Add one when creating a new view/data-template.
Expand Down
10 changes: 10 additions & 0 deletions Source/Trackify.Application/Catalog/LegoinoCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ public static class LegoinoCatalog
new(SensorActionType.ReverseDirection, "Richtung wechseln"),
];

/// <summary>The Streckenplaner's "Teil anhängen" toolbar (Gerade/Kurve links/Kurve rechts/Weiche/Bahnhof).</summary>
public static readonly IReadOnlyList<TrackPartOption> TrackParts =
[
new(SegmentType.Straight, null, "Gerade"),
new(SegmentType.Curve, CurveDirection.Left, "Kurve links"),
new(SegmentType.Curve, CurveDirection.Right, "Kurve rechts"),
new(SegmentType.Switch, null, "Weiche"),
new(SegmentType.Station, null, "Bahnhof"),
];

public static HubOption Hub(HubType value) => Hubs.First(h => h.Value == value);
public static DeviceOption Device(DeviceType value) => Devices.First(d => d.Value == value);
public static ColorOption Color(LedColorType value) => Colors.First(c => c.Value == value);
Expand Down
8 changes: 8 additions & 0 deletions Source/Trackify.Application/Catalog/TrackPartOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Trackify.Application.Catalog;

/// <summary>
/// Selectable track-part option for the Streckenplaner's "Teil anhängen" toolbar (Gerade/Kurve
/// links/Kurve rechts/Weiche/Bahnhof) — the segment <see cref="SegmentType"/> plus, for a curve, which
/// way it bends.
/// </summary>
public sealed record TrackPartOption(SegmentType Type, CurveDirection? Curve, string Label);
3 changes: 2 additions & 1 deletion Source/Trackify.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static class DependencyInjection
{
/// <summary>
/// Registers the Application use-case services (<see cref="ITrainControlService"/>,
/// <see cref="ITrainService"/>) and — filtered per platform right here in DI — the matching
/// <see cref="ITrainService"/>, <see cref="ITrackPlanService"/>) and — filtered per platform right here in DI — the matching
/// <see cref="ILegoService"/> transport via its <c>Add…Lego</c> helper (mirroring the CLI's
/// <c>AddLinuxLego</c>): Android → <c>AddAndroidLego</c>, iOS → <c>AddIosLego</c>, Windows →
/// <c>AddWindowsLego</c>. The plain net10.0 flavor registers none — the composition root decides
Expand All @@ -21,6 +21,7 @@ public static IServiceCollection AddTrackifyApplication(this IServiceCollection
{
services.AddSingleton<ITrainControlService, TrainControlService>();
services.AddSingleton<ITrainService, TrainService>();
services.AddSingleton<ITrackPlanService, TrackPlanService>();

#if __ANDROID__
services.AddAndroidLego();
Expand Down
45 changes: 45 additions & 0 deletions Source/Trackify.Application/Trains/ITrackPlanService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Trackify.Application.Trains;

/// <summary>Result of inserting a Weiche: the updated segment list plus the new branch strand's id.</summary>
public sealed record SwitchInsertResult(IReadOnlyList<TrackSegmentDto> Segments, Guid BranchStrandId);

/// <summary>
/// Shared use-case for building a track plan: every "smart" rule from the Streckenplaner design
/// (appending inherits speed/direction/f(x) from the predecessor, a Weiche spawns its own branch
/// strand, templates lay out a whole loop in one step) lives here instead of the ViewModel — the same
/// separation <see cref="ITrainControlService"/> gives train control. Operates on plain
/// <see cref="TrackSegmentDto"/> lists; callers own persistence via <see cref="ITrackSegmentRepository"/>.
/// </summary>
public interface ITrackPlanService
{
/// <summary>
/// Appends a new part to <paramref name="strandId"/>'s open (last) end. The new segment inherits
/// max speed, direction, and accel/brake functions from the strand's current last segment (or,
/// for a fresh branch strand, from the Weiche it branches off). <paramref name="curve"/> is
/// required when <paramref name="type"/> is <see cref="SegmentType.Curve"/>.
/// </summary>
IReadOnlyList<TrackSegmentDto> AppendPart(
IReadOnlyList<TrackSegmentDto> segments, Guid strandId, SegmentType type, CurveDirection? curve = null);

/// <summary>
/// Inserts a Weiche at <paramref name="strandId"/>'s open end and creates the branch strand it
/// peels off into (<paramref name="branchDirection"/>). The branch starts empty; the caller
/// decides whether to keep building on the trunk or switch the "active strand" to the branch.
/// </summary>
SwitchInsertResult InsertSwitch(IReadOnlyList<TrackSegmentDto> segments, Guid strandId, CurveDirection branchDirection);

/// <summary>Replaces every strand with a canned layout (Oval/Acht/Punkt-zu-Punkt), all on the main strand.</summary>
IReadOnlyList<TrackSegmentDto> ApplyTemplate(TemplateType template);

/// <summary>Duplicates one segment, inserting the copy immediately after it in the same strand.</summary>
IReadOnlyList<TrackSegmentDto> Duplicate(IReadOnlyList<TrackSegmentDto> segments, Guid segmentId);

/// <summary>Swaps a segment with its neighbour in travel order (<paramref name="up"/> = toward the start).</summary>
IReadOnlyList<TrackSegmentDto> Reorder(IReadOnlyList<TrackSegmentDto> segments, Guid segmentId, bool up);

/// <summary>Removes a segment. Deleting a Weiche also removes the branch strand it anchors.</summary>
IReadOnlyList<TrackSegmentDto> Delete(IReadOnlyList<TrackSegmentDto> segments, Guid segmentId);

/// <summary>Empties the plan entirely ("Leeren").</summary>
IReadOnlyList<TrackSegmentDto> Clear();
}
9 changes: 9 additions & 0 deletions Source/Trackify.Application/Trains/ITrackSegmentRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Trackify.Application.Common;

namespace Trackify.Application.Trains;

/// <summary>
/// Repository for the planned track segments — mirrors <see cref="ITrainRepository"/>: the default
/// CRUD from <see cref="IBaseRepository{T}"/> is all the front-ends need today.
/// </summary>
public interface ITrackSegmentRepository : IBaseRepository<TrackSegment>;
9 changes: 9 additions & 0 deletions Source/Trackify.Application/Trains/TemplateType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Trackify.Application.Trains;

/// <summary>A canned segment layout <see cref="ITrackPlanService.ApplyTemplate"/> can generate in one step.</summary>
public enum TemplateType
{
Oval,
Acht,
PunktZuPunkt,
}
185 changes: 185 additions & 0 deletions Source/Trackify.Application/Trains/TrackPlanService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
namespace Trackify.Application.Trains;

/// <inheritdoc cref="ITrackPlanService" />
public sealed class TrackPlanService : ITrackPlanService
{
public IReadOnlyList<TrackSegmentDto> AppendPart(
IReadOnlyList<TrackSegmentDto> segments, Guid strandId, SegmentType type, CurveDirection? curve = null)
{
var strand = StrandSorted(segments, strandId);
var predecessor = strand.Count > 0 ? strand[^1] : FindAnchor(segments, strandId);

var part = new TrackSegmentDto
{
Id = Guid.CreateVersion7(),
Name = NextName(segments, type, curve),
Type = type,
Curve = type == SegmentType.Curve ? curve ?? CurveDirection.Right : null,
MaxSpeed = predecessor?.MaxSpeed ?? 70,
Direction = predecessor?.Direction ?? TrackDirection.Forward,
AccelFn = predecessor?.AccelFn ?? SpeedFunctionType.EaseOut,
BrakeFn = predecessor?.BrakeFn ?? SpeedFunctionType.EaseIn,
StrandId = strandId,
Order = strand.Count,
};

return [.. segments, part];
}

public SwitchInsertResult InsertSwitch(IReadOnlyList<TrackSegmentDto> segments, Guid strandId, CurveDirection branchDirection)
{
var appended = AppendPart(segments, strandId, SegmentType.Switch);
var switchSegment = appended[^1];
var branchStrandId = Guid.CreateVersion7();

var updated = appended
.Select(s => s.Id == switchSegment.Id ? s with { BranchStrandId = branchStrandId, BranchDirection = branchDirection } : s)
.ToList();

return new SwitchInsertResult(updated, branchStrandId);
}

public IReadOnlyList<TrackSegmentDto> ApplyTemplate(TemplateType template)
{
var parts = template switch
{
TemplateType.Oval => OvalParts,
TemplateType.Acht => OvalParts.Concat(MirroredOvalParts),
TemplateType.PunktZuPunkt => PunktZuPunktParts,
_ => [],
};

var segments = new List<TrackSegmentDto>();
var order = 0;
foreach (var (type, curve) in parts)
{
segments.Add(new TrackSegmentDto
{
Id = Guid.CreateVersion7(),
Name = NextName(segments, type, curve),
Type = type,
Curve = curve,
MaxSpeed = 70,
StrandId = Guid.Empty,
Order = order++,
});
}

return segments;
}

public IReadOnlyList<TrackSegmentDto> Duplicate(IReadOnlyList<TrackSegmentDto> segments, Guid segmentId)
{
var source = segments.First(s => s.Id == segmentId);
var strand = StrandSorted(segments, source.StrandId);
var index = strand.FindIndex(s => s.Id == segmentId);

strand.Insert(index + 1, source with { Id = Guid.CreateVersion7(), Name = source.Name + " (Kopie)" });

return ReplaceStrand(segments, source.StrandId, strand);
}

public IReadOnlyList<TrackSegmentDto> Reorder(IReadOnlyList<TrackSegmentDto> segments, Guid segmentId, bool up)
{
var source = segments.First(s => s.Id == segmentId);
var strand = StrandSorted(segments, source.StrandId);
var index = strand.FindIndex(s => s.Id == segmentId);
var swapWith = up ? index - 1 : index + 1;

if (swapWith < 0 || swapWith >= strand.Count) return segments;

(strand[index], strand[swapWith]) = (strand[swapWith], strand[index]);

return ReplaceStrand(segments, source.StrandId, strand);
}

public IReadOnlyList<TrackSegmentDto> Delete(IReadOnlyList<TrackSegmentDto> segments, Guid segmentId)
{
var source = segments.First(s => s.Id == segmentId);
var remaining = segments.Where(s => s.Id != segmentId).ToList();

if (source.Type == SegmentType.Switch && source.BranchStrandId is { } branchId)
{
remaining = RemoveStrandCascade(remaining, branchId);
}

var strand = StrandSorted(remaining, source.StrandId);
return ReplaceStrand(remaining, source.StrandId, strand);
}

public IReadOnlyList<TrackSegmentDto> Clear() => [];

// Every strand's open end to append to: its own last segment by Order, or — for a fresh, empty
// branch strand — the Weiche that anchors it (so the branch's first part still inherits speed/
// direction/f(x), matching "erbt ... vom Vorgänger" even before the branch has segments of its own).
private static TrackSegmentDto? FindAnchor(IReadOnlyList<TrackSegmentDto> segments, Guid strandId)
=> strandId == Guid.Empty ? null : segments.FirstOrDefault(s => s.Type == SegmentType.Switch && s.BranchStrandId == strandId);

private static List<TrackSegmentDto> StrandSorted(IReadOnlyList<TrackSegmentDto> segments, Guid strandId)
=> [.. segments.Where(s => s.StrandId == strandId).OrderBy(s => s.Order)];

// Renumbers the given strand's Order to its list position and splices it back into the full set.
private static IReadOnlyList<TrackSegmentDto> ReplaceStrand(IReadOnlyList<TrackSegmentDto> segments, Guid strandId, List<TrackSegmentDto> strand)
{
for (var i = 0; i < strand.Count; i++) strand[i] = strand[i] with { Order = i };
return [.. segments.Where(s => s.StrandId != strandId), .. strand];
}

// Deleting a Weiche takes its whole branch with it, including any further Weichen nested inside.
private static List<TrackSegmentDto> RemoveStrandCascade(List<TrackSegmentDto> segments, Guid strandId)
{
var branchSegments = segments.Where(s => s.StrandId == strandId).ToList();
var result = segments.Where(s => s.StrandId != strandId).ToList();

foreach (var segment in branchSegments)
{
if (segment.Type == SegmentType.Switch && segment.BranchStrandId is { } nestedBranchId)
{
result = RemoveStrandCascade(result, nestedBranchId);
}
}

return result;
}

private static string NextName(IReadOnlyList<TrackSegmentDto> segments, SegmentType type, CurveDirection? curve)
=> $"{PartLabel(type, curve)} {segments.Count(s => s.Type == type && s.Curve == curve) + 1}";

private static string PartLabel(SegmentType type, CurveDirection? curve) => type switch
{
SegmentType.Straight => "Gerade",
SegmentType.Curve => curve == CurveDirection.Left ? "Kurve links" : "Kurve rechts",
SegmentType.Switch => "Weiche",
SegmentType.Station => "Bahnhof",
_ => "Segment",
};

// A running oval: half straight, half station (the "top" straight split at its midpoint, matching
// the original hard-coded seed), a 180° turn (two 90° curve pieces), the "bottom" straight (also
// split in two), then the opposite 180° turn back to the start heading.
private static readonly (SegmentType Type, CurveDirection? Curve)[] OvalParts =
[
(SegmentType.Straight, null), (SegmentType.Station, null),
(SegmentType.Curve, CurveDirection.Right), (SegmentType.Curve, CurveDirection.Right),
(SegmentType.Straight, null), (SegmentType.Straight, null),
(SegmentType.Curve, CurveDirection.Right), (SegmentType.Curve, CurveDirection.Right),
];

// The second lobe, turning the other way. OvalParts ends back at the start pose, so this one has to
// *lead* with its 180° turn: leading with straights (like OvalParts does) would retrace the first
// lobe's opening straight and station piece-for-piece — two segments stacked at identical
// coordinates. Turning away first and closing with the straights instead puts the lobe on the far
// side of the shared point, which is what makes the pair read as a figure-eight.
private static readonly (SegmentType Type, CurveDirection? Curve)[] MirroredOvalParts =
[
(SegmentType.Curve, CurveDirection.Left), (SegmentType.Curve, CurveDirection.Left),
(SegmentType.Straight, null), (SegmentType.Straight, null),
(SegmentType.Curve, CurveDirection.Left), (SegmentType.Curve, CurveDirection.Left),
(SegmentType.Straight, null), (SegmentType.Station, null),
];

private static readonly (SegmentType Type, CurveDirection? Curve)[] PunktZuPunktParts =
[
(SegmentType.Station, null), (SegmentType.Straight, null), (SegmentType.Straight, null), (SegmentType.Station, null),
];
}
Loading
Loading