From 198071300a555d4da1e8976738c157adeaa84b98 Mon Sep 17 00:00:00 2001
From: Spike Curtis <spike@coder.com>
Date: Fri, 7 Feb 2025 12:03:58 +0400
Subject: [PATCH] feat: Sign in Window views & view models

---
 App/App.csproj                    | 18 ++++++
 App/App.xaml.cs                   |  5 +-
 App/DisplayScale.cs               | 26 +++++++++
 App/SignInTokenPage.xaml          | 93 +++++++++++++++++++++++++++++++
 App/SignInTokenPage.xaml.cs       | 37 ++++++++++++
 App/SignInURLPage.xaml            | 53 ++++++++++++++++++
 App/SignInURLPage.xaml.cs         | 33 +++++++++++
 App/SignInWindow.xaml             | 15 +++++
 App/SignInWindow.xaml.cs          | 60 ++++++++++++++++++++
 App/TrayWindow.xaml               |  5 +-
 App/TrayWindow.xaml.cs            | 26 ++++-----
 App/ViewModels/SignInViewModel.cs | 45 +++++++++++++++
 12 files changed, 400 insertions(+), 16 deletions(-)
 create mode 100644 App/DisplayScale.cs
 create mode 100644 App/SignInTokenPage.xaml
 create mode 100644 App/SignInTokenPage.xaml.cs
 create mode 100644 App/SignInURLPage.xaml
 create mode 100644 App/SignInURLPage.xaml.cs
 create mode 100644 App/SignInWindow.xaml
 create mode 100644 App/SignInWindow.xaml.cs
 create mode 100644 App/ViewModels/SignInViewModel.cs

diff --git a/App/App.csproj b/App/App.csproj
index cae1812..5f06f25 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -41,6 +41,24 @@
         <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
     </ItemGroup>
 
+    <ItemGroup>
+      <Page Update="SignInTokenPage.xaml">
+        <Generator>MSBuild:Compile</Generator>
+      </Page>
+    </ItemGroup>
+
+    <ItemGroup>
+      <Page Update="SignInWindow.xaml">
+        <Generator>MSBuild:Compile</Generator>
+      </Page>
+    </ItemGroup>
+
+    <ItemGroup>
+      <Page Update="SignInURLPage.xaml">
+        <Generator>MSBuild:Compile</Generator>
+      </Page>
+    </ItemGroup>
+
     <ItemGroup>
         <Page Update="TrayIcon.xaml">
             <Generator>MSBuild:Compile</Generator>
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 4fbde75..6081d69 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -1,3 +1,4 @@
+using Coder.Desktop.App.ViewModels;
 using Microsoft.UI.Xaml;
 
 namespace Coder.Desktop.App;
@@ -5,9 +6,11 @@ namespace Coder.Desktop.App;
 public partial class App : Application
 {
     private TrayWindow? TrayWindow;
+    public SignInViewModel SignInViewModel { get; }
 
     public App()
     {
+        SignInViewModel = new SignInViewModel();
         InitializeComponent();
     }
 
@@ -15,7 +18,7 @@ public App()
 
     protected override void OnLaunched(LaunchActivatedEventArgs args)
     {
-        TrayWindow = new TrayWindow();
+        TrayWindow = new TrayWindow(SignInViewModel);
         TrayWindow.Closed += (sender, args) =>
         {
             // TODO: wire up HandleClosedEvents properly
diff --git a/App/DisplayScale.cs b/App/DisplayScale.cs
new file mode 100644
index 0000000..3712351
--- /dev/null
+++ b/App/DisplayScale.cs
@@ -0,0 +1,26 @@
+using System;
+using Microsoft.UI.Xaml;
+using WinRT.Interop;
+using System.Runtime.InteropServices;
+
+namespace Coder.Desktop.App;
+
+/// <summary>
+/// A static utility class to house methods related to the visual scale of the display monitor.
+/// </summary>
+public static class DisplayScale
+{
+    public static double WindowScale(Window win)
+    {
+        var hwnd = WindowNative.GetWindowHandle(win);
+        var dpi = NativeApi.GetDpiForWindow(hwnd);
+        if (dpi == 0) return 1; // assume scale of 1
+        return dpi / 96.0; // 96 DPI == 1
+    }
+
+    public class NativeApi
+    {
+        [DllImport("user32.dll")]
+        public static extern int GetDpiForWindow(IntPtr hwnd);
+    }
+}
diff --git a/App/SignInTokenPage.xaml b/App/SignInTokenPage.xaml
new file mode 100644
index 0000000..97194ae
--- /dev/null
+++ b/App/SignInTokenPage.xaml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Page
+    x:Class="Coder.Desktop.App.SignInTokenPage"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:local="using:Coder.Desktop.App"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    mc:Ignorable="d"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+
+    <StackPanel
+        Orientation="Vertical"
+        HorizontalAlignment="Stretch"
+        VerticalAlignment="Top"
+        Padding="20"
+        Spacing="10">
+
+        <TextBlock
+            Text="Coder Desktop"
+            FontSize="24"
+            VerticalAlignment="Center"
+            HorizontalAlignment="Center"/>
+
+        <Grid>
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition Width="Auto" />
+                <ColumnDefinition Width="*" />
+            </Grid.ColumnDefinitions>
+            <Grid.RowDefinitions>
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+                <RowDefinition Height="1*" />
+            </Grid.RowDefinitions>
+            
+            <TextBlock
+                Grid.Column="0"
+                Grid.Row="0"
+                Text="Server URL"
+                HorizontalAlignment="Right"
+                Padding="10"/>
+
+            <TextBlock
+                Grid.Column="1"
+                Grid.Row="0"
+                HorizontalAlignment="Stretch"
+                VerticalAlignment="Center"
+                Padding="10"
+                Text="{x:Bind Path=ViewModel.Url, Mode=OneTime}"
+                />
+
+            <TextBlock
+                Grid.Column="0"
+                Grid.Row="1"
+                Text="Session Token"
+                HorizontalAlignment="Right"
+                Padding="10"/>
+
+            <TextBox
+                Grid.Column="1"
+                Grid.Row="1"
+                HorizontalAlignment="Stretch"
+                PlaceholderText="paste your token here"
+                Text="{x:Bind Path=ViewModel.CoderToken, Mode=TwoWay}"
+                />
+
+            <HyperlinkButton
+                Grid.Column="1"
+                Grid.Row="2"
+                Content="Generate a token via the Web UI"
+                NavigateUri="{x:Bind ViewModel.GenTokenURL, Mode=OneTime}" />
+
+        </Grid>
+
+        <StackPanel
+            Orientation="Horizontal"
+            HorizontalAlignment="Center"
+            Spacing="10">
+            <Button Content="Back" HorizontalAlignment="Right"
+                    Click="{x:Bind SignInWindow.NavigateToURLPage}"/>
+            <Button Content="Sign In" HorizontalAlignment="Left" Style="{StaticResource AccentButtonStyle}"
+                    Click="{x:Bind ViewModel.SignIn_Click}"/>
+        </StackPanel>
+
+        <TextBlock
+            Text="{x:Bind ViewModel.LoginError, Mode=OneWay}"
+            HorizontalAlignment="Center"
+            Foreground="Red"
+            />
+       
+
+    </StackPanel>
+</Page>
diff --git a/App/SignInTokenPage.xaml.cs b/App/SignInTokenPage.xaml.cs
new file mode 100644
index 0000000..374ade8
--- /dev/null
+++ b/App/SignInTokenPage.xaml.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace Coder.Desktop.App
+{
+    /// <summary>
+    /// A sign in page to accept the user's Coder token.
+    /// </summary>
+    public sealed partial class SignInTokenPage : Page
+    {
+        public SignInTokenPage(SignInWindow parent, SignInViewModel viewModel)
+        {
+            InitializeComponent();
+            ViewModel = viewModel;
+            SignInWindow = parent;
+        }
+
+        public readonly SignInViewModel ViewModel;
+        public readonly SignInWindow SignInWindow;
+    }
+}
diff --git a/App/SignInURLPage.xaml b/App/SignInURLPage.xaml
new file mode 100644
index 0000000..d04fe26
--- /dev/null
+++ b/App/SignInURLPage.xaml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Page
+    x:Class="Coder.Desktop.App.SignInURLPage"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:local="using:Coder.Desktop.App"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    mc:Ignorable="d"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+
+    <StackPanel
+        Orientation="Vertical"
+        HorizontalAlignment="Stretch"
+        VerticalAlignment="Top"
+        Padding="20"
+        Spacing="10">
+
+        <TextBlock
+            Text="Coder Desktop"
+            FontSize="24"
+            VerticalAlignment="Center"
+            HorizontalAlignment="Center"/>
+
+        <Grid>
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition Width="Auto" />
+                <ColumnDefinition Width="*" />
+            </Grid.ColumnDefinitions>
+            <TextBlock
+                Grid.Column="0"
+                Text="Server URL"
+                HorizontalAlignment="Right"
+                Padding="10"/>
+
+            <TextBox
+                Name="URLTextBox"
+                Grid.Column="1"
+                HorizontalAlignment="Stretch"
+                PlaceholderText="https://coder.example.com"
+                Text="{x:Bind Path=ViewModel.Url, Mode=TwoWay}"
+                />
+
+        </Grid>
+
+        <Button
+            Content="Next"
+            HorizontalAlignment="Center"
+            Click="{x:Bind SignInWindow.NavigateToTokenPage}"
+            Style="{StaticResource AccentButtonStyle}" />
+
+    </StackPanel>
+</Page>
diff --git a/App/SignInURLPage.xaml.cs b/App/SignInURLPage.xaml.cs
new file mode 100644
index 0000000..8c1cf49
--- /dev/null
+++ b/App/SignInURLPage.xaml.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+
+namespace Coder.Desktop.App;
+
+/// <summary>
+/// A login page to enter the Coder Server URL
+/// </summary>
+public sealed partial class SignInURLPage : Page
+{
+    public SignInURLPage(SignInWindow parent, SignInViewModel viewModel)
+    {
+        InitializeComponent();
+        ViewModel = viewModel;
+        SignInWindow = parent;
+    }
+
+    public readonly SignInViewModel ViewModel;
+    public readonly SignInWindow SignInWindow;
+}
diff --git a/App/SignInWindow.xaml b/App/SignInWindow.xaml
new file mode 100644
index 0000000..8dc397f
--- /dev/null
+++ b/App/SignInWindow.xaml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Window
+    x:Class="Coder.Desktop.App.SignInWindow"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:local="using:Coder.Desktop.App"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    mc:Ignorable="d"
+    Title="Sign in to Coder">
+
+    <Window.SystemBackdrop>
+        <DesktopAcrylicBackdrop />
+    </Window.SystemBackdrop>
+</Window>
diff --git a/App/SignInWindow.xaml.cs b/App/SignInWindow.xaml.cs
new file mode 100644
index 0000000..96db63b
--- /dev/null
+++ b/App/SignInWindow.xaml.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Coder.Desktop.App.ViewModels;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+using Windows.Graphics;
+
+namespace Coder.Desktop.App;
+
+/// <summary>
+/// The dialog window to allow the user to sign into their Coder server.
+/// </summary>
+public sealed partial class SignInWindow : Window
+{
+    private const double WIDTH = 600.0;
+    private const double HEIGHT = 300.0;
+
+    private readonly SignInViewModel viewModel;
+    public SignInWindow(SignInViewModel vm)
+    {
+        viewModel = vm;
+        InitializeComponent();
+        var urlPage = new SignInURLPage(this, viewModel);
+        Content = urlPage;
+        ResizeWindow();
+    }
+
+    [RelayCommand]
+    public void NavigateToTokenPage()
+    {
+        var tokenPage = new SignInTokenPage(this, viewModel);
+        Content = tokenPage;
+    }
+
+    [RelayCommand]
+    public void NavigateToURLPage()
+    {
+        var urlPage = new SignInURLPage(this, viewModel);
+        Content = urlPage;
+    }
+
+    private void ResizeWindow()
+    {
+        var scale = DisplayScale.WindowScale(this);
+        var height = (int)(HEIGHT * scale);
+        var width = (int)(WIDTH * scale);
+        AppWindow.Resize(new SizeInt32(width, height));
+    }
+}
diff --git a/App/TrayWindow.xaml b/App/TrayWindow.xaml
index a779bb5..a2f737a 100644
--- a/App/TrayWindow.xaml
+++ b/App/TrayWindow.xaml
@@ -159,9 +159,10 @@
         <HyperlinkButton
             Margin="-10,0,-10,0"
             HorizontalAlignment="Stretch"
-            HorizontalContentAlignment="Left">
+            HorizontalContentAlignment="Left"
+            Click="{x:Bind SignIn_Click}">
 
-            <TextBlock Text="Sign out" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+            <TextBlock Text="Sign in" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
         </HyperlinkButton>
 
         <!-- For some strange reason, setting OpenCommand and ExitCommand here doesn't work, see .cs -->
diff --git a/App/TrayWindow.xaml.cs b/App/TrayWindow.xaml.cs
index fcd1c0e..54b0476 100644
--- a/App/TrayWindow.xaml.cs
+++ b/App/TrayWindow.xaml.cs
@@ -19,6 +19,7 @@
 using Microsoft.UI.Xaml.Media;
 using WinRT.Interop;
 using WindowActivatedEventArgs = Microsoft.UI.Xaml.WindowActivatedEventArgs;
+using Coder.Desktop.App.ViewModels;
 
 namespace Coder.Desktop.App;
 
@@ -139,8 +140,11 @@ public sealed partial class TrayWindow : Window
         },
     ];
 
-    public TrayWindow()
+    public SignInViewModel SignInViewModel { get; }
+
+    public TrayWindow(SignInViewModel signInViewModel)
     {
+        SignInViewModel = signInViewModel;
         InitializeComponent();
         AppWindow.Hide();
         SystemBackdrop = new DesktopAcrylicBackdrop();
@@ -190,20 +194,12 @@ private void ResizeWindow()
         var desiredSize = content.DesiredSize;
 
         // Adjust the AppWindow size
-        var scale = GetDisplayScale();
+        var scale = DisplayScale.WindowScale(this);
         var height = (int)(desiredSize.Height * scale);
         var width = (int)(WIDTH * scale);
         AppWindow.Resize(new SizeInt32(width, height));
     }
 
-    private double GetDisplayScale()
-    {
-        var hwnd = WindowNative.GetWindowHandle(this);
-        var dpi = NativeApi.GetDpiForWindow(hwnd);
-        if (dpi == 0) return 1; // assume scale of 1
-        return dpi / 96.0; // 96 DPI == 1
-    }
-
     public void MoveResizeAndActivate()
     {
         SaveCursorPos();
@@ -313,13 +309,17 @@ public class NativeApi
         [DllImport("user32.dll")]
         public static extern bool SetForegroundWindow(IntPtr hwnd);
 
-        [DllImport("user32.dll")]
-        public static extern int GetDpiForWindow(IntPtr hwnd);
-
         public struct POINT
         {
             public int X;
             public int Y;
         }
     }
+
+    [RelayCommand]
+    private void SignIn_Click()
+    {
+        var window = new SignInWindow(SignInViewModel);
+        window.Activate();
+    }
 }
diff --git a/App/ViewModels/SignInViewModel.cs b/App/ViewModels/SignInViewModel.cs
new file mode 100644
index 0000000..ea233f8
--- /dev/null
+++ b/App/ViewModels/SignInViewModel.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace Coder.Desktop.App.ViewModels;
+
+/// <summary>
+/// The View Model backing the sign in window and all its associated pages.
+/// </summary>
+public partial class SignInViewModel : ObservableObject
+{
+    public SignInViewModel()
+    {
+        _url = string.Empty;
+        CoderToken = string.Empty;
+    }
+
+    [ObservableProperty]
+    private string _url;
+
+    public string CoderToken;
+
+    [ObservableProperty]
+    private string? _loginError;
+
+    [RelayCommand]
+    public void SignIn_Click()
+    {
+        // TODO: this should call into the backing model to do the login with _url and Token.
+        LoginError = "This is a placeholder error.";
+    }
+
+    public string GenTokenURL
+    {
+        get
+        {
+            // TODO: use a real URL parsing library
+            return _url + "/cli-auth";
+        }
+    }
+}