|
| 1 | +using System.IO; |
| 2 | +using System.Text.Json; |
| 3 | +using System.Windows; |
| 4 | +using System.Windows.Automation; |
| 5 | +using System.Windows.Controls; |
| 6 | +using System.Windows.Media; |
| 7 | +using LifeOS.Core.Forms; |
| 8 | +using LifeOS.Core.LocalFirstSync; |
| 9 | +using LifeOS.Shared.LocalFirstSync; |
| 10 | +using LifeOS.Shared.Storage; |
| 11 | + |
| 12 | +namespace LifeOS.Desktop; |
| 13 | + |
| 14 | +public sealed class LocalFirstSyncWorkspaceView : UserControl |
| 15 | +{ |
| 16 | + private readonly bool _demo; |
| 17 | + private List<LocalAccountSyncProfile> _profiles = []; |
| 18 | + private IReadOnlyList<FormFieldIssue> _issues = []; |
| 19 | + private UserFacingProblem? _problem; |
| 20 | + private string? _notice; |
| 21 | + private string _displayName = string.Empty; |
| 22 | + private string _deviceLabel = string.Empty; |
| 23 | + private LocalAccountMode _mode = LocalAccountMode.LocalOnly; |
| 24 | + |
| 25 | + public LocalFirstSyncWorkspaceView(bool demo) |
| 26 | + { |
| 27 | + _demo = demo; |
| 28 | + Background = Brush("#0C1220"); |
| 29 | + Foreground = Brushes.White; |
| 30 | + FontFamily = new FontFamily("Segoe UI"); |
| 31 | + try { _profiles = LocalFirstSyncStorage.Load(); } |
| 32 | + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) |
| 33 | + { |
| 34 | + _problem = UserFacingProblemFactory.FromException(ex, "load the local account profile"); |
| 35 | + } |
| 36 | + Render(); |
| 37 | + } |
| 38 | + |
| 39 | + private void Render() |
| 40 | + { |
| 41 | + LocalAccountSyncProfile? profile = _profiles.SingleOrDefault(); |
| 42 | + StackPanel root = new() { Margin = new Thickness(24) }; |
| 43 | + root.Children.Add(Text(_demo ? "PORTFOLIO DEMO • ISOLATED" : "ORDINARY MODE • LOCAL-FIRST", 11, "#AFA4FF", FontWeights.SemiBold)); |
| 44 | + root.Children.Add(Text("Local Account & Sync Foundation", 30, "#FFFFFF", FontWeights.Bold)); |
| 45 | + root.Children.Add(Text("Name this local workspace and choose a local-only or manual-transfer posture. No sign-in, cloud account, background sync, upload or provider write is available.", 14, "#B8C5D8")); |
| 46 | + if (_notice is not null) root.Children.Add(Text(_notice, 12, "#83D4B3", FontWeights.SemiBold)); |
| 47 | + if (_problem is not null) root.Children.Add(Problem(_problem)); |
| 48 | + |
| 49 | + WrapPanel metrics = new() { Margin = new Thickness(0, 16, 0, 8) }; |
| 50 | + metrics.Children.Add(Metric("Local profile", profile is null ? "Not set" : "Ready", "No authentication identity")); |
| 51 | + metrics.Children.Add(Metric("Pending local changes", (profile?.PendingLocalChanges ?? 0).ToString(), "Local counter only")); |
| 52 | + metrics.Children.Add(Metric("Cloud provider", "Not configured", "Credentials required later")); |
| 53 | + metrics.Children.Add(Metric("Background sync", "Off", "Cannot auto-resume")); |
| 54 | + root.Children.Add(metrics); |
| 55 | + |
| 56 | + if (profile is null) root.Children.Add(Capture()); |
| 57 | + else root.Children.Add(ProfileCard(profile)); |
| 58 | + |
| 59 | + root.Children.Add(Heading("Configuration boundary", 21, new Thickness(0, 20, 0, 6))); |
| 60 | + root.Children.Add(Card("Development can continue without credentials", "This foundation validates local identity labels, persists one recoverable profile and records local change pressure. Authentication, encryption keys, endpoint selection, remote conflict exchange and actual transfer remain unavailable until explicitly configured and tested.", "#152437")); |
| 61 | + LocalStoreHealth health = LocalFirstSyncStorage.Inspect(); |
| 62 | + root.Children.Add(Card("Versioned local-account-sync store", $"State: {health.State}. Recovery is available in Local Data & Recovery. Reset moves the profile to recoverable Trash.", "#151F30")); |
| 63 | + Content = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, Content = root }; |
| 64 | + } |
| 65 | + |
| 66 | + private UIElement Capture() |
| 67 | + { |
| 68 | + StackPanel body = new(); |
| 69 | + body.Children.Add(Heading("Create a local workspace profile", 20)); |
| 70 | + body.Children.Add(Text("This is a device-local label, not a login or remote account.", 12, "#A9B6CA")); |
| 71 | + WrapPanel fields = new(); |
| 72 | + fields.Children.Add(Input("Display name *", "local-account-name", "LocalSync.DisplayName", _displayName, value => _displayName = value, 480)); |
| 73 | + fields.Children.Add(Input("Device label *", "local-device-label", "LocalSync.DeviceLabel", _deviceLabel, value => _deviceLabel = value, 480)); |
| 74 | + body.Children.Add(fields); |
| 75 | + body.Children.Add(Choice("Data movement posture", "LocalSync.Mode", Enum.GetValues<LocalAccountMode>(), _mode, value => _mode = value, 480)); |
| 76 | + Button create = Button("Create local profile", "LocalSync.Create", false); |
| 77 | + create.Click += (_, _) => Create(); |
| 78 | + body.Children.Add(create); |
| 79 | + return Panel(body, new Thickness(0, 14, 0, 0)); |
| 80 | + } |
| 81 | + |
| 82 | + private UIElement ProfileCard(LocalAccountSyncProfile profile) |
| 83 | + { |
| 84 | + StackPanel body = new(); |
| 85 | + DockPanel header = new(); |
| 86 | + TextBlock state = Text(profile.State.ToString().ToUpperInvariant(), 11, "#AFA4FF", FontWeights.SemiBold); |
| 87 | + DockPanel.SetDock(state, Dock.Right); |
| 88 | + header.Children.Add(state); |
| 89 | + header.Children.Add(Heading(profile.DisplayName, 20)); |
| 90 | + body.Children.Add(header); |
| 91 | + body.Children.Add(Text($"Device: {profile.DeviceLabel} • posture: {profile.Mode}", 12, "#A9B6CA")); |
| 92 | + body.Children.Add(Text($"Pending local changes: {profile.PendingLocalChanges}. Provider configured: no. Background sync: off.", 13, "#E7ECF4", FontWeights.SemiBold)); |
| 93 | + if (profile.LastManualTransferUtc is not null) body.Children.Add(Text($"Last acknowledged manual transfer: {profile.LastManualTransferUtc:yyyy-MM-dd HH:mm} UTC", 11, "#A9B6CA")); |
| 94 | + WrapPanel actions = new(); |
| 95 | + if (profile.State != LocalSyncState.Paused) |
| 96 | + { |
| 97 | + Button change = Button("Record local change", "LocalSync.RecordChange", true); |
| 98 | + change.Click += (_, _) => Change(profile, value => LocalFirstSyncService.RegisterLocalChange(value, DateTimeOffset.UtcNow), "Recorded one local change."); |
| 99 | + actions.Children.Add(change); |
| 100 | + if (profile.Mode == LocalAccountMode.ManualTransfer) |
| 101 | + { |
| 102 | + Button transfer = Button("Acknowledge manual transfer", "LocalSync.RecordTransfer", true); |
| 103 | + transfer.Click += (_, _) => Change(profile, value => LocalFirstSyncService.RecordManualTransfer(value, DateTimeOffset.UtcNow), "Recorded a manual-transfer checkpoint. LifeOS did not upload anything."); |
| 104 | + actions.Children.Add(transfer); |
| 105 | + } |
| 106 | + Button pause = Button("Pause local change tracking", "LocalSync.Pause", true); |
| 107 | + pause.Click += (_, _) => Change(profile, value => LocalFirstSyncService.SetPaused(value, true, DateTimeOffset.UtcNow), "Local change tracking paused."); |
| 108 | + actions.Children.Add(pause); |
| 109 | + } |
| 110 | + else |
| 111 | + { |
| 112 | + Button resume = Button("Resume local change tracking", "LocalSync.Resume", true); |
| 113 | + resume.Click += (_, _) => Change(profile, value => LocalFirstSyncService.SetPaused(value, false, DateTimeOffset.UtcNow), "Local change tracking resumed."); |
| 114 | + actions.Children.Add(resume); |
| 115 | + } |
| 116 | + body.Children.Add(actions); |
| 117 | + return Panel(body, new Thickness(0, 14, 0, 0)); |
| 118 | + } |
| 119 | + |
| 120 | + private void Create() |
| 121 | + { |
| 122 | + LocalAccountDraft draft = new(_displayName, _deviceLabel, _mode); |
| 123 | + FormValidationResult validation = LocalFirstSyncService.Validate(draft); |
| 124 | + _issues = validation.Issues; |
| 125 | + if (!validation.IsValid) |
| 126 | + { |
| 127 | + _problem = new UserFacingProblem("local-account-validation-failed", "Review the local profile fields", "No local account profile was added because one or more fields are invalid.", "Correct the highlighted fields, then try again.", true); |
| 128 | + Render(); |
| 129 | + return; |
| 130 | + } |
| 131 | + try |
| 132 | + { |
| 133 | + LocalAccountSyncProfile profile = LocalFirstSyncService.Create(draft, DateTimeOffset.UtcNow); |
| 134 | + LocalFirstSyncStorage.Save([profile]); |
| 135 | + _profiles = [profile]; |
| 136 | + _issues = []; |
| 137 | + _problem = null; |
| 138 | + _notice = "Created a local-only workspace profile. No remote account was created."; |
| 139 | + Render(); |
| 140 | + } |
| 141 | + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) |
| 142 | + { |
| 143 | + _problem = UserFacingProblemFactory.FromException(ex, "save the local account profile"); |
| 144 | + Render(); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + private void Change(LocalAccountSyncProfile profile, Func<LocalAccountSyncProfile, LocalAccountSyncProfile> change, string notice) |
| 149 | + { |
| 150 | + try |
| 151 | + { |
| 152 | + LocalAccountSyncProfile changed = change(profile); |
| 153 | + LocalFirstSyncStorage.Save([changed]); |
| 154 | + _profiles = [changed]; |
| 155 | + _problem = null; |
| 156 | + _notice = notice; |
| 157 | + Render(); |
| 158 | + } |
| 159 | + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or InvalidOperationException or OverflowException) |
| 160 | + { |
| 161 | + _problem = UserFacingProblemFactory.FromException(ex, "change the local sync state"); |
| 162 | + Render(); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + private UIElement Input(string label, string fieldId, string automationId, string value, Action<string> changed, double width) |
| 167 | + { |
| 168 | + StackPanel field = new() { Width = width, Margin = new Thickness(0, 7, 10, 0) }; |
| 169 | + field.Children.Add(Text(label, 12, "#C7D2E3", FontWeights.SemiBold)); |
| 170 | + TextBox input = new() { Text = value, MinHeight = 38, Background = Brush("#101827"), Foreground = Brushes.White, BorderBrush = Brush("#3A4B66"), Padding = new Thickness(10, 7, 10, 7) }; |
| 171 | + AutomationProperties.SetAutomationId(input, automationId); |
| 172 | + input.TextChanged += (_, _) => changed(input.Text); |
| 173 | + field.Children.Add(input); |
| 174 | + string error = string.Join(" ", _issues.Where(issue => issue.FieldId == fieldId).Select(issue => issue.Message)); |
| 175 | + if (error.Length > 0) field.Children.Add(Text(error, 11, "#FF7788", FontWeights.SemiBold)); |
| 176 | + return field; |
| 177 | + } |
| 178 | + |
| 179 | + private static UIElement Choice<T>(string label, string automationId, IReadOnlyList<T> values, T selected, Action<T> changed, double width) where T : struct |
| 180 | + { |
| 181 | + StackPanel field = new() { Width = width, Margin = new Thickness(0, 7, 10, 0) }; |
| 182 | + field.Children.Add(Text(label, 12, "#C7D2E3", FontWeights.SemiBold)); |
| 183 | + ComboBox input = new() { ItemsSource = values, SelectedItem = selected, MinHeight = 38 }; |
| 184 | + AutomationProperties.SetAutomationId(input, automationId); |
| 185 | + input.SelectionChanged += (_, _) => { if (input.SelectedItem is T value) changed(value); }; |
| 186 | + field.Children.Add(input); |
| 187 | + return field; |
| 188 | + } |
| 189 | + |
| 190 | + private static Border Problem(UserFacingProblem problem) { StackPanel body = new(); body.Children.Add(Heading($"{problem.Title} ({problem.Code})", 16)); body.Children.Add(Text(problem.Detail, 12, "#E1E7F0")); body.Children.Add(Text($"Next: {problem.RecoveryAction}", 12, "#C5AECF")); return new Border { Background = Brush("#251925"), BorderBrush = Brush("#C95F75"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(15), Margin = new Thickness(0, 12, 0, 0), Child = body }; } |
| 191 | + private static Button Button(string label, string id, bool secondary) { Button button = new() { Content = label, Background = Brush(secondary ? "#25334A" : "#315E91"), Foreground = Brushes.White, BorderBrush = Brush(secondary ? "#405472" : "#477DB4"), Padding = new Thickness(14, 7, 14, 7), Margin = new Thickness(0, 8, 8, 0), MinHeight = 36 }; AutomationProperties.SetAutomationId(button, id); return button; } |
| 192 | + private static Border Metric(string label, string value, string detail) { StackPanel body = new(); body.Children.Add(Text(label, 11, "#9EACC0")); body.Children.Add(Text(value, 22, "#FFFFFF", FontWeights.SemiBold)); body.Children.Add(Text(detail, 11, "#9EACC0")); return new Border { Width = 230, MinHeight = 100, Background = Brush("#151F30"), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(14), Margin = new Thickness(0, 0, 10, 10), Child = body }; } |
| 193 | + private static Border Card(string title, string body, string background) { StackPanel content = new(); content.Children.Add(Heading(title, 16)); content.Children.Add(Text(body, 12, "#C2CDDC")); return new Border { Background = Brush(background), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(16), Margin = new Thickness(0, 8, 0, 0), Child = content }; } |
| 194 | + private static Border Panel(UIElement child, Thickness margin) => new() { Background = Brush("#151F30"), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(16), Margin = margin, Child = child }; |
| 195 | + private static TextBlock Heading(string text, double size, Thickness? margin = null) => new() { Text = text, FontSize = size, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, TextWrapping = TextWrapping.Wrap, Margin = margin ?? new Thickness(0, 0, 0, 4) }; |
| 196 | + private static TextBlock Text(string text, double size, string color, FontWeight? weight = null) => new() { Text = text, FontSize = size, FontWeight = weight ?? FontWeights.Normal, Foreground = Brush(color), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 3, 0, 0) }; |
| 197 | + private static SolidColorBrush Brush(string value) => new((Color)ColorConverter.ConvertFromString(value)); |
| 198 | +} |
0 commit comments