JT
12 months ago
committed by
GitHub
137 changed files with 7827 additions and 2400 deletions
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 50 KiB |
@ -0,0 +1,13 @@ |
|||||||
|
using System; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Like <see cref="HyperlinkButton"/>, but with a link icon left of the text content. |
||||||
|
/// </summary> |
||||||
|
public class HyperlinkIconButton : HyperlinkButton |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
protected override Type StyleKeyOverride => typeof(HyperlinkIconButton); |
||||||
|
} |
@ -0,0 +1,124 @@ |
|||||||
|
<Styles |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<StackPanel Width="600" Height="400"> |
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
Header="Service 1" |
||||||
|
IconSource="OtherUser" |
||||||
|
OffDescription="Manage account services like A, B, and C" /> |
||||||
|
|
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
Header="Service 1 (Loading)" |
||||||
|
IconSource="OtherUser" |
||||||
|
IsLoading="True" |
||||||
|
OffDescription="Manage account services like A, B, and C" /> |
||||||
|
|
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
Header="Service 2" |
||||||
|
IconSource="CloudFilled" |
||||||
|
IsConnected="True" |
||||||
|
OffDescription="Manage account services like A, B, and C" /> |
||||||
|
|
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
Header="Service 3" |
||||||
|
IconSource="CloudFilled" |
||||||
|
IsConnected="True" |
||||||
|
OnDescriptionExtra="(account)" |
||||||
|
OffDescription="Manage account services like A, B, and C" /> |
||||||
|
</StackPanel> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="controls|SettingsAccountLinkExpander"> |
||||||
|
<!-- Set Defaults --> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<ui:SettingsExpander x:Name="PART_SettingsExpander" IconSource="{TemplateBinding IconSource}"> |
||||||
|
<ui:SettingsExpander.Header> |
||||||
|
<StackPanel> |
||||||
|
<TextBlock x:Name="PART_HeaderTextBlock" Text="{TemplateBinding Header}" /> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
IsVisible="{TemplateBinding IsConnected, |
||||||
|
Mode=OneWay, |
||||||
|
Converter={x:Static BoolConverters.Not}}" |
||||||
|
Text="{TemplateBinding OffDescription}" |
||||||
|
TextWrapping="Wrap" |
||||||
|
Theme="{DynamicResource CaptionTextBlockStyle}" /> |
||||||
|
|
||||||
|
|
||||||
|
<StackPanel |
||||||
|
x:Name="PART_OnDescriptionPanel" |
||||||
|
IsVisible="{TemplateBinding IsConnected, |
||||||
|
Mode=OneWay}" |
||||||
|
Orientation="Horizontal" |
||||||
|
Spacing="4"> |
||||||
|
<Ellipse |
||||||
|
Width="5" |
||||||
|
Height="5" |
||||||
|
Fill="{StaticResource ThemeMediumSeaGreenColor}" /> |
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
Text="{TemplateBinding OnDescription}" |
||||||
|
TextWrapping="Wrap" |
||||||
|
Theme="{DynamicResource CaptionTextBlockStyle}" /> |
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
Text="{TemplateBinding OnDescriptionExtra}" |
||||||
|
TextWrapping="Wrap" |
||||||
|
Theme="{DynamicResource CaptionTextBlockStyle}" /> |
||||||
|
</StackPanel> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
</ui:SettingsExpander.Header> |
||||||
|
|
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<StackPanel Margin="0,0,12,0" Orientation="Horizontal"> |
||||||
|
<!-- for some reason direct bind to IsConnected doesn't work here --> |
||||||
|
|
||||||
|
<controls:ProgressRing |
||||||
|
Margin="0,0,24,0" |
||||||
|
BorderThickness="3" |
||||||
|
IsIndeterminate="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}" |
||||||
|
IsVisible="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}" /> |
||||||
|
|
||||||
|
|
||||||
|
<!-- Connect button --> |
||||||
|
<Button |
||||||
|
x:Name="PART_ConnectButton" |
||||||
|
Padding="32,6" |
||||||
|
Command="{TemplateBinding ConnectCommand}" |
||||||
|
Content="{x:Static lang:Resources.Action_Connect}" |
||||||
|
IsVisible="{Binding !IsVisible, ElementName=PART_OnDescriptionPanel}" /> |
||||||
|
|
||||||
|
<!-- Disconnect button --> |
||||||
|
<Button |
||||||
|
x:Name="PART_DisconnectButton" |
||||||
|
Padding="6,8" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
BorderThickness="0" |
||||||
|
Classes="transparent" |
||||||
|
IsVisible="{Binding IsVisible, ElementName=PART_OnDescriptionPanel}"> |
||||||
|
<ui:SymbolIcon FontSize="20" Symbol="More" /> |
||||||
|
<Button.Flyout> |
||||||
|
<ui:FAMenuFlyout Placement="BottomEdgeAlignedLeft"> |
||||||
|
<ui:MenuFlyoutItem |
||||||
|
x:Name="PART_DisconnectMenuItem" |
||||||
|
Command="{TemplateBinding DisconnectCommand}" |
||||||
|
Text="{x:Static lang:Resources.Action_Disconnect}" /> |
||||||
|
</ui:FAMenuFlyout> |
||||||
|
</Button.Flyout> |
||||||
|
</Button> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
|
||||||
|
</ui:SettingsExpander> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,199 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Windows.Input; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Metadata; |
||||||
|
using Avalonia.VisualTree; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class SettingsAccountLinkExpander : TemplatedControl |
||||||
|
{ |
||||||
|
private readonly List<object?> _items = new(); |
||||||
|
|
||||||
|
[Content] |
||||||
|
public List<object?> Items => _items; |
||||||
|
|
||||||
|
// ReSharper disable MemberCanBePrivate.Global |
||||||
|
public static readonly StyledProperty<object?> HeaderProperty = |
||||||
|
HeaderedItemsControl.HeaderProperty.AddOwner<SettingsAccountLinkExpander>(); |
||||||
|
|
||||||
|
public object? Header |
||||||
|
{ |
||||||
|
get => GetValue(HeaderProperty); |
||||||
|
set => SetValue(HeaderProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<Uri?> HeaderTargetUriProperty = AvaloniaProperty.Register< |
||||||
|
SettingsAccountLinkExpander, |
||||||
|
Uri? |
||||||
|
>("HeaderTargetUri"); |
||||||
|
|
||||||
|
public Uri? HeaderTargetUri |
||||||
|
{ |
||||||
|
get => GetValue(HeaderTargetUriProperty); |
||||||
|
set => SetValue(HeaderTargetUriProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<IconSource?> IconSourceProperty = |
||||||
|
SettingsExpander.IconSourceProperty.AddOwner<SettingsAccountLinkExpander>(); |
||||||
|
|
||||||
|
public IconSource? IconSource |
||||||
|
{ |
||||||
|
get => GetValue(IconSourceProperty); |
||||||
|
set => SetValue(IconSourceProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsConnectedProperty = AvaloniaProperty.Register< |
||||||
|
SettingsAccountLinkExpander, |
||||||
|
bool |
||||||
|
>("IsConnected"); |
||||||
|
|
||||||
|
public bool IsConnected |
||||||
|
{ |
||||||
|
get => GetValue(IsConnectedProperty); |
||||||
|
set => SetValue(IsConnectedProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> OnDescriptionProperty = |
||||||
|
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>( |
||||||
|
"OnDescription", |
||||||
|
Languages.Resources.Label_Connected |
||||||
|
); |
||||||
|
|
||||||
|
public object? OnDescription |
||||||
|
{ |
||||||
|
get => GetValue(OnDescriptionProperty); |
||||||
|
set => SetValue(OnDescriptionProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> OnDescriptionExtraProperty = |
||||||
|
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>("OnDescriptionExtra"); |
||||||
|
|
||||||
|
public object? OnDescriptionExtra |
||||||
|
{ |
||||||
|
get => GetValue(OnDescriptionExtraProperty); |
||||||
|
set => SetValue(OnDescriptionExtraProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> OffDescriptionProperty = |
||||||
|
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>("OffDescription"); |
||||||
|
|
||||||
|
public object? OffDescription |
||||||
|
{ |
||||||
|
get => GetValue(OffDescriptionProperty); |
||||||
|
set => SetValue(OffDescriptionProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<ICommand?> ConnectCommandProperty = |
||||||
|
AvaloniaProperty.Register<SettingsAccountLinkExpander, ICommand?>( |
||||||
|
nameof(ConnectCommand), |
||||||
|
enableDataValidation: true |
||||||
|
); |
||||||
|
|
||||||
|
public ICommand? ConnectCommand |
||||||
|
{ |
||||||
|
get => GetValue(ConnectCommandProperty); |
||||||
|
set => SetValue(ConnectCommandProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<ICommand?> DisconnectCommandProperty = |
||||||
|
AvaloniaProperty.Register<SettingsAccountLinkExpander, ICommand?>( |
||||||
|
nameof(DisconnectCommand), |
||||||
|
enableDataValidation: true |
||||||
|
); |
||||||
|
|
||||||
|
public ICommand? DisconnectCommand |
||||||
|
{ |
||||||
|
get => GetValue(DisconnectCommandProperty); |
||||||
|
set => SetValue(DisconnectCommandProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
/*public static readonly StyledProperty<bool> IsLoading2Property = AvaloniaProperty.Register<SettingsAccountLinkExpander, bool>( |
||||||
|
nameof(IsLoading2)); |
||||||
|
|
||||||
|
public bool IsLoading2 |
||||||
|
{ |
||||||
|
get => GetValue(IsLoading2Property); |
||||||
|
set => SetValue(IsLoading2Property, value); |
||||||
|
}*/ |
||||||
|
|
||||||
|
private bool _isLoading; |
||||||
|
|
||||||
|
public static readonly DirectProperty<SettingsAccountLinkExpander, bool> IsLoadingProperty = |
||||||
|
AvaloniaProperty.RegisterDirect<SettingsAccountLinkExpander, bool>( |
||||||
|
"IsLoading", |
||||||
|
o => o.IsLoading, |
||||||
|
(o, v) => o.IsLoading = v |
||||||
|
); |
||||||
|
|
||||||
|
public bool IsLoading |
||||||
|
{ |
||||||
|
get => _isLoading; |
||||||
|
set => SetAndRaise(IsLoadingProperty, ref _isLoading, value); |
||||||
|
} |
||||||
|
|
||||||
|
// ReSharper restore MemberCanBePrivate.Global |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnApplyTemplate(e); |
||||||
|
|
||||||
|
// Bind tapped event on header |
||||||
|
if ( |
||||||
|
HeaderTargetUri is { } headerTargetUri |
||||||
|
&& e.NameScope.Find<TextBlock>("PART_HeaderTextBlock") is { } headerTextBlock |
||||||
|
) |
||||||
|
{ |
||||||
|
headerTextBlock.Tapped += (_, _) => |
||||||
|
{ |
||||||
|
ProcessRunner.OpenUrl(headerTargetUri.ToString()); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (e.NameScope.Find<SettingsExpander>("PART_SettingsExpander") is { } expander) |
||||||
|
{ |
||||||
|
expander.ItemsSource = Items; |
||||||
|
} |
||||||
|
|
||||||
|
if (ConnectCommand is { } command) |
||||||
|
{ |
||||||
|
var connectButton = e.NameScope.Get<Button>("PART_ConnectButton"); |
||||||
|
connectButton.Command = command; |
||||||
|
} |
||||||
|
|
||||||
|
if (DisconnectCommand is { } disconnectCommand) |
||||||
|
{ |
||||||
|
var disconnectMenuItem = e.NameScope.Get<MenuFlyoutItem>("PART_DisconnectMenuItem"); |
||||||
|
disconnectMenuItem.Command = disconnectCommand; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
|
||||||
|
if (!this.IsAttachedToVisualTree()) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (change.Property == ConnectCommandProperty) |
||||||
|
{ |
||||||
|
var button = this.GetControl<Button>("PART_ConnectButton"); |
||||||
|
button.Command = ConnectCommand; |
||||||
|
} |
||||||
|
|
||||||
|
if (change.Property == DisconnectCommandProperty) |
||||||
|
{ |
||||||
|
var button = this.GetControl<Button>("PART_DisconnectButton"); |
||||||
|
button.Command = DisconnectCommand; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,59 @@ |
|||||||
|
<Styles xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="using:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:icons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<StackPanel Width="400" Height="400" Spacing="4"> |
||||||
|
<StackPanel.Styles> |
||||||
|
<Style Selector="controls|Card"> |
||||||
|
<Setter Property="Padding" Value="6,4"/> |
||||||
|
<Setter Property="HorizontalAlignment" Value="Left"/> |
||||||
|
</Style> |
||||||
|
</StackPanel.Styles> |
||||||
|
|
||||||
|
<controls:Card> |
||||||
|
<controls:StarsRating Value="1" /> |
||||||
|
</controls:Card> |
||||||
|
|
||||||
|
<controls:Card Classes="transparent"> |
||||||
|
<controls:StarsRating Value="2" /> |
||||||
|
</controls:Card> |
||||||
|
|
||||||
|
<controls:Card Classes="transparent"> |
||||||
|
<controls:StarsRating Value="2.5" /> |
||||||
|
</controls:Card> |
||||||
|
|
||||||
|
</StackPanel> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<!--<Styles.Resources> |
||||||
|
<icons:SymbolIcon |
||||||
|
x:Key="StarFilledIcon" |
||||||
|
FontSize="15" |
||||||
|
Margin="8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Symbol="Star" |
||||||
|
IsFilled="True"/> |
||||||
|
</Styles.Resources>--> |
||||||
|
|
||||||
|
<Style Selector="controls|StarsRating"> |
||||||
|
<!-- Set Defaults --> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<ItemsControl |
||||||
|
x:Name="PART_StarsItemsControl"> |
||||||
|
<ItemsControl.ItemsPanel> |
||||||
|
<ItemsPanelTemplate> |
||||||
|
<StackPanel Spacing="2" Orientation="Horizontal" /> |
||||||
|
</ItemsPanelTemplate> |
||||||
|
</ItemsControl.ItemsPanel> |
||||||
|
</ItemsControl> |
||||||
|
<!--<sg:SpacedGrid |
||||||
|
x:Name="PART_Grid"> |
||||||
|
~1~ Stars filled dynamically @1@ |
||||||
|
</sg:SpacedGrid>--> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</Style> |
||||||
|
</Styles> |
@ -0,0 +1,168 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Controls.Documents; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Data; |
||||||
|
using Avalonia.Layout; |
||||||
|
using Avalonia.Markup.Xaml.MarkupExtensions; |
||||||
|
using Avalonia.Media; |
||||||
|
using Avalonia.VisualTree; |
||||||
|
using FluentIcons.Common; |
||||||
|
using FluentIcons.FluentAvalonia; |
||||||
|
using SpacedGridControl.Avalonia; |
||||||
|
using StabilityMatrix.Avalonia.Styles; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
|
public class StarsRating : TemplatedControl |
||||||
|
{ |
||||||
|
private SymbolIcon? StarFilledIcon => Resources["StarFilledIcon"] as SymbolIcon; |
||||||
|
|
||||||
|
private ItemsControl? itemsControl; |
||||||
|
|
||||||
|
private IEnumerable<SymbolIcon> StarItems => itemsControl!.ItemsSource!.Cast<SymbolIcon>(); |
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsEditableProperty = AvaloniaProperty.Register< |
||||||
|
StarsRating, |
||||||
|
bool |
||||||
|
>("IsEditable"); |
||||||
|
|
||||||
|
public bool IsEditable |
||||||
|
{ |
||||||
|
get => GetValue(IsEditableProperty); |
||||||
|
set => SetValue(IsEditableProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<int> MaximumProperty = AvaloniaProperty.Register< |
||||||
|
StarsRating, |
||||||
|
int |
||||||
|
>(nameof(Maximum), 5); |
||||||
|
|
||||||
|
public int Maximum |
||||||
|
{ |
||||||
|
get => GetValue(MaximumProperty); |
||||||
|
set => SetValue(MaximumProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly StyledProperty<double> ValueProperty = AvaloniaProperty.Register< |
||||||
|
StarsRating, |
||||||
|
double |
||||||
|
>(nameof(Value)); |
||||||
|
|
||||||
|
public double Value |
||||||
|
{ |
||||||
|
get => GetValue(ValueProperty); |
||||||
|
set => SetValue(ValueProperty, value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) |
||||||
|
{ |
||||||
|
base.OnApplyTemplate(e); |
||||||
|
|
||||||
|
itemsControl = e.NameScope.Find<ItemsControl>("PART_StarsItemsControl")!; |
||||||
|
|
||||||
|
CreateStars(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
||||||
|
{ |
||||||
|
base.OnPropertyChanged(change); |
||||||
|
|
||||||
|
if (!this.IsAttachedToVisualTree()) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (change.Property == ValueProperty || change.Property == MaximumProperty) |
||||||
|
{ |
||||||
|
SyncStarState(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void CreateStars() |
||||||
|
{ |
||||||
|
if (itemsControl is null) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Fill stars |
||||||
|
var stars = new List<Control>(); |
||||||
|
|
||||||
|
for (var i = 0; i < Maximum; i++) |
||||||
|
{ |
||||||
|
var star = new SymbolIcon |
||||||
|
{ |
||||||
|
FontSize = FontSize, |
||||||
|
Margin = new Thickness(0, 0), |
||||||
|
Symbol = Symbol.Star, |
||||||
|
HorizontalAlignment = HorizontalAlignment.Center, |
||||||
|
VerticalAlignment = VerticalAlignment.Center, |
||||||
|
Tag = i |
||||||
|
}; |
||||||
|
|
||||||
|
stars.Add(star); |
||||||
|
OnStarAdded(star); |
||||||
|
} |
||||||
|
|
||||||
|
itemsControl.ItemsSource = stars; |
||||||
|
SyncStarState(); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnStarAdded(SymbolIcon item) |
||||||
|
{ |
||||||
|
if (IsEditable) |
||||||
|
{ |
||||||
|
item.Tapped += (sender, args) => |
||||||
|
{ |
||||||
|
var star = (SymbolIcon)sender!; |
||||||
|
Value = (int)star.Tag! + 1; |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Round a number to the nearest 0.5 |
||||||
|
/// </summary> |
||||||
|
private static double RoundToHalf(double value) |
||||||
|
{ |
||||||
|
return Math.Round(value * 2, MidpointRounding.AwayFromZero) / 2; |
||||||
|
} |
||||||
|
|
||||||
|
private void SyncStarState() |
||||||
|
{ |
||||||
|
// Set star to filled when Value is greater than or equal to the star index |
||||||
|
foreach (var star in StarItems) |
||||||
|
{ |
||||||
|
// Add 1 to tag since its index is 0-based |
||||||
|
var tag = (int)star.Tag! + 1; |
||||||
|
|
||||||
|
// Fill if current is equal or lower than floor of Value |
||||||
|
if (tag <= Math.Floor(RoundToHalf(Value))) |
||||||
|
{ |
||||||
|
star.Symbol = Symbol.Star; |
||||||
|
star.IsFilled = true; |
||||||
|
star.Foreground = Foreground; |
||||||
|
} |
||||||
|
// If current is between floor and ceil of value, use half-star |
||||||
|
else if (tag <= Math.Ceiling(RoundToHalf(Value))) |
||||||
|
{ |
||||||
|
star.Symbol = Symbol.StarHalf; |
||||||
|
star.IsFilled = true; |
||||||
|
star.Foreground = Foreground; |
||||||
|
} |
||||||
|
// Otherwise no fill and gray disabled color |
||||||
|
else |
||||||
|
{ |
||||||
|
star.Symbol = Symbol.Star; |
||||||
|
star.IsFilled = false; |
||||||
|
star.Foreground = new SolidColorBrush(Colors.DarkSlateGray); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class BooleanChoiceMultiConverter : IMultiValueConverter |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public object? Convert( |
||||||
|
IList<object?> values, |
||||||
|
Type targetType, |
||||||
|
object? parameter, |
||||||
|
CultureInfo culture |
||||||
|
) |
||||||
|
{ |
||||||
|
if (values.Count < 3) |
||||||
|
{ |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (values[0] is bool boolValue) |
||||||
|
{ |
||||||
|
return boolValue ? values[1] : values[2]; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class EnumStringConverter : IValueConverter |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
if (value is not Enum enumValue) |
||||||
|
return null; |
||||||
|
|
||||||
|
return enumValue.GetStringValue(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public object? ConvertBack( |
||||||
|
object? value, |
||||||
|
Type targetType, |
||||||
|
object? parameter, |
||||||
|
CultureInfo culture |
||||||
|
) |
||||||
|
{ |
||||||
|
if (value is not string stringValue) |
||||||
|
return null; |
||||||
|
|
||||||
|
return Enum.Parse(targetType, stringValue); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class EnumToBooleanConverter : IValueConverter |
||||||
|
{ |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
return value?.Equals(parameter); |
||||||
|
} |
||||||
|
|
||||||
|
public object? ConvertBack( |
||||||
|
object? value, |
||||||
|
Type targetType, |
||||||
|
object? parameter, |
||||||
|
CultureInfo culture |
||||||
|
) |
||||||
|
{ |
||||||
|
return value?.Equals(true) == true ? parameter : BindingOperations.DoNothing; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,50 @@ |
|||||||
|
using System; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class KiloFormatter : ICustomFormatter, IFormatProvider |
||||||
|
{ |
||||||
|
public object? GetFormat(Type? formatType) |
||||||
|
{ |
||||||
|
return formatType == typeof(ICustomFormatter) ? this : null; |
||||||
|
} |
||||||
|
|
||||||
|
public string Format(string? format, object? arg, IFormatProvider? formatProvider) |
||||||
|
{ |
||||||
|
if (format == null || !format.Trim().StartsWith('K')) |
||||||
|
{ |
||||||
|
if (arg is IFormattable formatArg) |
||||||
|
{ |
||||||
|
return formatArg.ToString(format, formatProvider); |
||||||
|
} |
||||||
|
|
||||||
|
return arg?.ToString() ?? string.Empty; |
||||||
|
} |
||||||
|
|
||||||
|
var value = Convert.ToInt64(arg); |
||||||
|
|
||||||
|
return FormatNumber(value); |
||||||
|
} |
||||||
|
|
||||||
|
private static string FormatNumber(long num) |
||||||
|
{ |
||||||
|
if (num >= 100000000) |
||||||
|
{ |
||||||
|
return (num / 1000000D).ToString("0.#M"); |
||||||
|
} |
||||||
|
if (num >= 1000000) |
||||||
|
{ |
||||||
|
return (num / 1000000D).ToString("0.##M"); |
||||||
|
} |
||||||
|
if (num >= 100000) |
||||||
|
{ |
||||||
|
return (num / 1000D).ToString("0.#K"); |
||||||
|
} |
||||||
|
if (num >= 10000) |
||||||
|
{ |
||||||
|
return (num / 1000D).ToString("0.##K"); |
||||||
|
} |
||||||
|
|
||||||
|
return num.ToString("#,0"); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
using System; |
||||||
|
using System.Globalization; |
||||||
|
using Avalonia.Data.Converters; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Converters; |
||||||
|
|
||||||
|
public class KiloFormatterStringConverter : IValueConverter |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||||
|
{ |
||||||
|
return value is null ? null : string.Format(new KiloFormatter(), "{0:K}", value); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public object? ConvertBack( |
||||||
|
object? value, |
||||||
|
Type targetType, |
||||||
|
object? parameter, |
||||||
|
CultureInfo culture |
||||||
|
) |
||||||
|
{ |
||||||
|
return value is null ? null : throw new NotImplementedException(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,102 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Diagnostics.CodeAnalysis; |
||||||
|
using System.IO; |
||||||
|
using System.Runtime.Versioning; |
||||||
|
using System.Text.Json; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using MessagePipe; |
||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
using Microsoft.Win32; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using URIScheme; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Helpers; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Custom URI scheme handler for the stabilitymatrix:// protocol |
||||||
|
/// </summary> |
||||||
|
/// <remarks>Need to call <see cref="RegisterUriScheme"/> on App startup</remarks> |
||||||
|
public class UriHandler |
||||||
|
{ |
||||||
|
public const string IpcKeySend = "uri_handler_send"; |
||||||
|
|
||||||
|
public string Scheme { get; } |
||||||
|
public string Description { get; } |
||||||
|
|
||||||
|
public UriHandler(string scheme, string description) |
||||||
|
{ |
||||||
|
Scheme = scheme; |
||||||
|
Description = description; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Send a received Uri over MessagePipe and exits |
||||||
|
/// </summary> |
||||||
|
/// <param name="uri"></param> |
||||||
|
[DoesNotReturn] |
||||||
|
public void SendAndExit(Uri uri) |
||||||
|
{ |
||||||
|
var services = new ServiceCollection(); |
||||||
|
services.AddMessagePipe(); |
||||||
|
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix"); |
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider(); |
||||||
|
var publisher = provider.GetRequiredService<IDistributedPublisher<string, Uri>>(); |
||||||
|
|
||||||
|
var sendTask = Task.Run(async () => await publisher.PublishAsync(IpcKeySend, uri)); |
||||||
|
sendTask.Wait(); |
||||||
|
|
||||||
|
var info = JsonSerializer.Serialize(new Dictionary<string, Uri> { [IpcKeySend] = uri }); |
||||||
|
|
||||||
|
Debug.WriteLine(info); |
||||||
|
Console.WriteLine(info); |
||||||
|
|
||||||
|
Environment.Exit(0); |
||||||
|
} |
||||||
|
|
||||||
|
public void Callback() { } |
||||||
|
|
||||||
|
public void RegisterUriScheme() |
||||||
|
{ |
||||||
|
if (Compat.IsWindows) |
||||||
|
{ |
||||||
|
RegisterUriSchemeWin(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
RegisterUriSchemeUnix(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")] |
||||||
|
private void RegisterUriSchemeWin() |
||||||
|
{ |
||||||
|
using var key = Registry.CurrentUser.CreateSubKey(@$"SOFTWARE\Classes\{Scheme}"); |
||||||
|
|
||||||
|
key.SetValue("", "URL:" + Description); |
||||||
|
key.SetValue(null, Description); |
||||||
|
key.SetValue("URL Protocol", ""); |
||||||
|
|
||||||
|
using (var defaultIcon = key.CreateSubKey("DefaultIcon")) |
||||||
|
{ |
||||||
|
defaultIcon.SetValue("", Compat.AppCurrentPath.FullPath + ",1"); |
||||||
|
} |
||||||
|
|
||||||
|
using (var commandKey = key.CreateSubKey(@"shell\open\command")) |
||||||
|
{ |
||||||
|
commandKey.SetValue("", "\"" + Compat.AppCurrentPath.FullPath + "\" --uri \"%1\""); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void RegisterUriSchemeUnix() |
||||||
|
{ |
||||||
|
var service = URISchemeServiceFactory.GetURISchemeSerivce( |
||||||
|
Scheme, |
||||||
|
Description, |
||||||
|
Compat.AppCurrentPath.FullPath |
||||||
|
); |
||||||
|
service.Set(); |
||||||
|
} |
||||||
|
} |
@ -1,43 +1,87 @@ |
|||||||
namespace StabilityMatrix.Avalonia.Models; |
using System; |
||||||
|
using CommandLine; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Command line arguments passed to the application. |
/// Command line arguments passed to the application. |
||||||
/// </summary> |
/// </summary> |
||||||
public class AppArgs |
public class AppArgs |
||||||
{ |
{ |
||||||
|
/// <summary> |
||||||
|
/// Whether to enable debug mode |
||||||
|
/// </summary> |
||||||
|
[Option("debug", HelpText = "Enable debug mode")] |
||||||
|
public bool DebugMode { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Whether to use the exception dialog while debugger is attached. |
/// Whether to use the exception dialog while debugger is attached. |
||||||
/// When no debugger is attached, the exception dialog is always used. |
/// When no debugger is attached, the exception dialog is always used. |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("debug-exception-dialog", HelpText = "Use exception dialog while debugger is attached")] |
||||||
public bool DebugExceptionDialog { get; set; } |
public bool DebugExceptionDialog { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Whether to use Sentry when a debugger is attached. |
/// Whether to use Sentry when a debugger is attached. |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("debug-sentry", HelpText = "Use Sentry when debugger is attached")] |
||||||
public bool DebugSentry { get; set; } |
public bool DebugSentry { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Whether to force show the one-click install dialog. |
/// Whether to force show the one-click install dialog. |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("debug-one-click-install", HelpText = "Force show the one-click install dialog")] |
||||||
public bool DebugOneClickInstall { get; set; } |
public bool DebugOneClickInstall { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Whether to disable Sentry. |
/// Whether to disable Sentry. |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("no-sentry", HelpText = "Disable Sentry")] |
||||||
public bool NoSentry { get; set; } |
public bool NoSentry { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Whether to disable window chrome effects |
/// Whether to disable window chrome effects |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("no-window-chrome-effects", HelpText = "Disable window chrome effects")] |
||||||
public bool NoWindowChromeEffects { get; set; } |
public bool NoWindowChromeEffects { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Flag to indicate if we should reset the saved window position back to (O,0) |
/// Flag to indicate if we should reset the saved window position back to (O,0) |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("reset-window-position", HelpText = "Reset the saved window position back to (0,0)")] |
||||||
public bool ResetWindowPosition { get; set; } |
public bool ResetWindowPosition { get; set; } |
||||||
|
|
||||||
/// <summary> |
/// <summary> |
||||||
/// Flag for disabling hardware acceleration / GPU rendering |
/// Flag for disabling hardware acceleration / GPU rendering |
||||||
/// </summary> |
/// </summary> |
||||||
|
[Option("disable-gpu-rendering", HelpText = "Disable hardware acceleration / GPU rendering")] |
||||||
public bool DisableGpuRendering { get; set; } |
public bool DisableGpuRendering { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Override global app home directory |
||||||
|
/// Defaults to (%APPDATA%|~/.config)/StabilityMatrix |
||||||
|
/// </summary> |
||||||
|
[Option("home-dir", HelpText = "Override global app home directory")] |
||||||
|
public string? HomeDirectoryOverride { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Override data directory |
||||||
|
/// This takes precedence over relative portable directory and global directory |
||||||
|
/// </summary> |
||||||
|
[Option("data-dir", HelpText = "Override data directory")] |
||||||
|
public string? DataDirectoryOverride { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Custom Uri protocol handler |
||||||
|
/// This will send the Uri to the running instance of the app via IPC and exit |
||||||
|
/// </summary> |
||||||
|
[Option("uri", Hidden = true)] |
||||||
|
public string? Uri { get; set; } |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// If provided, the app will wait for the process with this PID to exit |
||||||
|
/// before starting up. Mainly used by the updater. |
||||||
|
/// </summary> |
||||||
|
[Option("wait-for-exit-pid", Hidden = true)] |
||||||
|
public int? WaitForExitPid { get; set; } |
||||||
} |
} |
||||||
|
@ -0,0 +1,10 @@ |
|||||||
|
using System; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
public class TypedNavigationEventArgs : EventArgs |
||||||
|
{ |
||||||
|
public required Type ViewModelType { get; init; } |
||||||
|
|
||||||
|
public object? ViewModel { get; init; } |
||||||
|
} |
@ -0,0 +1,70 @@ |
|||||||
|
using System; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using Semver; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models.Update; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
public partial class UpdateChannelCard : ObservableObject |
||||||
|
{ |
||||||
|
public UpdateChannel UpdateChannel { get; init; } |
||||||
|
|
||||||
|
public string DisplayName => UpdateChannel.GetStringValue(); |
||||||
|
|
||||||
|
public string? Description { get; init; } |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(LatestVersionString))] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsLatestVersionUpdateable))] |
||||||
|
private SemVersion? latestVersion; |
||||||
|
|
||||||
|
public string? LatestVersionString => |
||||||
|
LatestVersion is null ? null : $"Latest: v{LatestVersion}"; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isSelectable = true; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Whether the <see cref="LatestVersion"/> is available for update. |
||||||
|
/// </summary> |
||||||
|
public bool IsLatestVersionUpdateable |
||||||
|
{ |
||||||
|
get |
||||||
|
{ |
||||||
|
if (LatestVersion is null) |
||||||
|
{ |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
switch (LatestVersion.ComparePrecedenceTo(Compat.AppVersion)) |
||||||
|
{ |
||||||
|
case > 0: |
||||||
|
// Newer version available |
||||||
|
return true; |
||||||
|
case 0: |
||||||
|
{ |
||||||
|
// Same version available, check if we both have commit hash metadata |
||||||
|
var updateHash = LatestVersion.Metadata; |
||||||
|
var appHash = Compat.AppVersion.Metadata; |
||||||
|
|
||||||
|
// Trim both to the lower length, to a minimum of 7 characters |
||||||
|
var minLength = Math.Min(7, Math.Min(updateHash.Length, appHash.Length)); |
||||||
|
updateHash = updateHash[..minLength]; |
||||||
|
appHash = appHash[..minLength]; |
||||||
|
|
||||||
|
// If different, we can update |
||||||
|
if (updateHash != appHash) |
||||||
|
{ |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
using System; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Models; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// |
||||||
|
/// </summary> |
||||||
|
[Flags] |
||||||
|
public enum ViewModelState : uint |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// View Model has been initially loaded |
||||||
|
/// </summary> |
||||||
|
InitialLoaded = 1 << 0, |
||||||
|
} |
@ -0,0 +1,254 @@ |
|||||||
|
using System; |
||||||
|
using System.Net; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using Octokit; |
||||||
|
using StabilityMatrix.Core.Api; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Api.CivitTRPC; |
||||||
|
using StabilityMatrix.Core.Models.Api.Lykos; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using ApiException = Refit.ApiException; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Services; |
||||||
|
|
||||||
|
[Singleton(typeof(IAccountsService))] |
||||||
|
public class AccountsService : IAccountsService |
||||||
|
{ |
||||||
|
private readonly ILogger<AccountsService> logger; |
||||||
|
private readonly ISecretsManager secretsManager; |
||||||
|
private readonly ILykosAuthApi lykosAuthApi; |
||||||
|
private readonly ICivitTRPCApi civitTRPCApi; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public event EventHandler<LykosAccountStatusUpdateEventArgs>? LykosAccountStatusUpdate; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public event EventHandler<CivitAccountStatusUpdateEventArgs>? CivitAccountStatusUpdate; |
||||||
|
|
||||||
|
public LykosAccountStatusUpdateEventArgs? LykosStatus { get; private set; } |
||||||
|
|
||||||
|
public CivitAccountStatusUpdateEventArgs? CivitStatus { get; private set; } |
||||||
|
|
||||||
|
public AccountsService( |
||||||
|
ILogger<AccountsService> logger, |
||||||
|
ISecretsManager secretsManager, |
||||||
|
ILykosAuthApi lykosAuthApi, |
||||||
|
ICivitTRPCApi civitTRPCApi |
||||||
|
) |
||||||
|
{ |
||||||
|
this.logger = logger; |
||||||
|
this.secretsManager = secretsManager; |
||||||
|
this.lykosAuthApi = lykosAuthApi; |
||||||
|
this.civitTRPCApi = civitTRPCApi; |
||||||
|
|
||||||
|
// Update our own status when the Lykos account status changes |
||||||
|
LykosAccountStatusUpdate += (_, args) => LykosStatus = args; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task LykosLoginAsync(string email, string password) |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
|
||||||
|
var tokens = await lykosAuthApi.PostLogin(new PostLoginRequest(email, password)); |
||||||
|
|
||||||
|
secrets = secrets with { LykosAccount = tokens }; |
||||||
|
|
||||||
|
await secretsManager.SaveAsync(secrets); |
||||||
|
|
||||||
|
await RefreshLykosAsync(secrets); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task LykosSignupAsync(string email, string password, string username) |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
|
||||||
|
var tokens = await lykosAuthApi.PostAccount( |
||||||
|
new PostAccountRequest(email, password, password, username) |
||||||
|
); |
||||||
|
|
||||||
|
secrets = secrets with { LykosAccount = tokens }; |
||||||
|
|
||||||
|
await secretsManager.SaveAsync(secrets); |
||||||
|
|
||||||
|
await RefreshLykosAsync(secrets); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task LykosLogoutAsync() |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
await secretsManager.SaveAsync(secrets with { LykosAccount = null }); |
||||||
|
|
||||||
|
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public async Task LykosPatreonOAuthLogoutAsync() |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
if (secrets.LykosAccount is null) |
||||||
|
{ |
||||||
|
throw new InvalidOperationException( |
||||||
|
"Lykos account must be connected in to manage OAuth connections" |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
await lykosAuthApi.DeletePatreonOAuth(); |
||||||
|
|
||||||
|
await RefreshLykosAsync(secrets); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task CivitLoginAsync(string apiToken) |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
|
||||||
|
// Get id first using the api token |
||||||
|
var userAccount = await civitTRPCApi.GetUserAccountDefault(apiToken); |
||||||
|
var id = userAccount.Result.Data.Json.Id; |
||||||
|
|
||||||
|
// Then get the username using the id |
||||||
|
var account = await civitTRPCApi.GetUserById( |
||||||
|
new CivitGetUserByIdRequest { Id = id }, |
||||||
|
apiToken |
||||||
|
); |
||||||
|
var username = account.Result.Data.Json.Username; |
||||||
|
|
||||||
|
secrets = secrets with { CivitApi = new CivitApiTokens(apiToken, username) }; |
||||||
|
|
||||||
|
await secretsManager.SaveAsync(secrets); |
||||||
|
|
||||||
|
await RefreshCivitAsync(secrets); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public async Task CivitLogoutAsync() |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
await secretsManager.SaveAsync(secrets with { CivitApi = null }); |
||||||
|
|
||||||
|
OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task RefreshAsync() |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync(); |
||||||
|
|
||||||
|
await RefreshLykosAsync(secrets); |
||||||
|
await RefreshCivitAsync(secrets); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task RefreshLykosAsync(Secrets secrets) |
||||||
|
{ |
||||||
|
if ( |
||||||
|
secrets.LykosAccount is not null |
||||||
|
&& !string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken) |
||||||
|
&& !string.IsNullOrWhiteSpace(secrets.LykosAccount?.AccessToken) |
||||||
|
) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var user = await lykosAuthApi.GetUserSelf(); |
||||||
|
|
||||||
|
OnLykosAccountStatusUpdate( |
||||||
|
new LykosAccountStatusUpdateEventArgs { IsConnected = true, User = user } |
||||||
|
); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
logger.LogWarning("Timed out while fetching Lykos Auth user info"); |
||||||
|
} |
||||||
|
catch (InvalidOperationException e) |
||||||
|
{ |
||||||
|
logger.LogWarning(e, "Failed to get authentication token"); |
||||||
|
} |
||||||
|
catch (ApiException e) |
||||||
|
{ |
||||||
|
if (e.StatusCode is HttpStatusCode.Unauthorized) { } |
||||||
|
else |
||||||
|
{ |
||||||
|
logger.LogWarning(e, "Failed to get user info from Lykos"); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
logger.LogError(e, "Unknown error while refreshing Lykos account status"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task RefreshCivitAsync(Secrets secrets) |
||||||
|
{ |
||||||
|
if (secrets.CivitApi is not null) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var user = await civitTRPCApi.GetUserProfile( |
||||||
|
new CivitUserProfileRequest { Username = secrets.CivitApi.Username }, |
||||||
|
secrets.CivitApi.ApiToken |
||||||
|
); |
||||||
|
|
||||||
|
OnCivitAccountStatusUpdate( |
||||||
|
new CivitAccountStatusUpdateEventArgs { IsConnected = true, UserProfile = user } |
||||||
|
); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
logger.LogWarning("Timed out while fetching Civit Auth user info"); |
||||||
|
} |
||||||
|
catch (ApiException e) |
||||||
|
{ |
||||||
|
if (e.StatusCode is HttpStatusCode.Unauthorized) { } |
||||||
|
else |
||||||
|
{ |
||||||
|
logger.LogWarning(e, "Failed to get user info from Civit"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs e) |
||||||
|
{ |
||||||
|
if (!e.IsConnected && LykosStatus?.IsConnected == true) |
||||||
|
{ |
||||||
|
logger.LogInformation("Lykos account disconnected"); |
||||||
|
} |
||||||
|
else if (e.IsConnected && LykosStatus?.IsConnected == false) |
||||||
|
{ |
||||||
|
logger.LogInformation( |
||||||
|
"Lykos account connected: {Id} ({Username})", |
||||||
|
e.User?.Id, |
||||||
|
e.User?.Account.Name |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
LykosAccountStatusUpdate?.Invoke(this, e); |
||||||
|
} |
||||||
|
|
||||||
|
private void OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs e) |
||||||
|
{ |
||||||
|
if (!e.IsConnected && CivitStatus?.IsConnected == true) |
||||||
|
{ |
||||||
|
logger.LogInformation("Civit account disconnected"); |
||||||
|
} |
||||||
|
else if (e.IsConnected && CivitStatus?.IsConnected == false) |
||||||
|
{ |
||||||
|
logger.LogInformation( |
||||||
|
"Civit account connected: {Id} ({Username})", |
||||||
|
e.UserProfile?.UserId, |
||||||
|
e.UserProfile?.Username |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
CivitAccountStatusUpdate?.Invoke(this, e); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,29 @@ |
|||||||
|
using System; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Api.Lykos; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Services; |
||||||
|
|
||||||
|
public interface IAccountsService |
||||||
|
{ |
||||||
|
event EventHandler<LykosAccountStatusUpdateEventArgs>? LykosAccountStatusUpdate; |
||||||
|
|
||||||
|
event EventHandler<CivitAccountStatusUpdateEventArgs>? CivitAccountStatusUpdate; |
||||||
|
|
||||||
|
LykosAccountStatusUpdateEventArgs? LykosStatus { get; } |
||||||
|
|
||||||
|
Task LykosSignupAsync(string email, string password, string username); |
||||||
|
|
||||||
|
Task LykosLoginAsync(string email, string password); |
||||||
|
|
||||||
|
Task LykosLogoutAsync(); |
||||||
|
|
||||||
|
Task LykosPatreonOAuthLogoutAsync(); |
||||||
|
|
||||||
|
Task CivitLoginAsync(string apiToken); |
||||||
|
|
||||||
|
Task CivitLogoutAsync(); |
||||||
|
|
||||||
|
Task RefreshAsync(); |
||||||
|
} |
@ -0,0 +1,44 @@ |
|||||||
|
<ResourceDictionary |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"> |
||||||
|
|
||||||
|
<!-- ReSharper disable once Xaml.StaticResourceNotResolved --> |
||||||
|
<ControlTheme |
||||||
|
x:Key="{x:Type controls:HyperlinkIconButton}" |
||||||
|
BasedOn="{StaticResource {x:Type ui:HyperlinkButton}}" |
||||||
|
TargetType="controls:HyperlinkIconButton"> |
||||||
|
<Setter Property="ContentTemplate"> |
||||||
|
<DataTemplate DataType="x:String"> |
||||||
|
<StackPanel Orientation="Horizontal"> |
||||||
|
<ui:SymbolIcon |
||||||
|
Margin="0,1,4,0" |
||||||
|
FontSize="15" |
||||||
|
Foreground="{DynamicResource HyperlinkButtonForeground}" |
||||||
|
Symbol="Link" /> |
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource HyperlinkButtonForeground}" Text="{Binding}" /> |
||||||
|
</StackPanel> |
||||||
|
</DataTemplate> |
||||||
|
</Setter> |
||||||
|
<!-- Override template to not set Underline TextDecoration --> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<ContentPresenter Name="ContentPresenter" |
||||||
|
Background="{TemplateBinding Background}" |
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||||
|
Content="{TemplateBinding Content}" |
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}" |
||||||
|
Padding="{TemplateBinding Padding}" |
||||||
|
CornerRadius="{TemplateBinding CornerRadius}" |
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||||
|
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" |
||||||
|
Foreground="{TemplateBinding Foreground}"> |
||||||
|
</ContentPresenter> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
</ControlTheme> |
||||||
|
|
||||||
|
</ResourceDictionary> |
@ -0,0 +1,25 @@ |
|||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> |
||||||
|
|
||||||
|
<!-- ListBoxItem theme that removes ListBox visuals (i.e. for using with radiobuttons) --> |
||||||
|
<ControlTheme x:Key="ListBoxItemBorderlessTheme" TargetType="ListBoxItem"> |
||||||
|
<Setter Property="Template"> |
||||||
|
<ControlTemplate> |
||||||
|
<Panel> |
||||||
|
<ContentPresenter |
||||||
|
Name="PART_ContentPresenter" |
||||||
|
Margin="2,0" |
||||||
|
Padding="{TemplateBinding Padding}" |
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||||
|
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" |
||||||
|
Background="{TemplateBinding Background}" |
||||||
|
BorderBrush="{TemplateBinding BorderBrush}" |
||||||
|
BorderThickness="{TemplateBinding BorderThickness}" |
||||||
|
Content="{TemplateBinding Content}" |
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}" |
||||||
|
CornerRadius="{TemplateBinding CornerRadius}" /> |
||||||
|
</Panel> |
||||||
|
</ControlTemplate> |
||||||
|
</Setter> |
||||||
|
|
||||||
|
</ControlTheme> |
||||||
|
</ResourceDictionary> |
@ -0,0 +1,77 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using System.Windows.Input; |
||||||
|
using Avalonia.Threading; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Languages; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Base class for view models that are used in <see cref="FluentAvalonia.UI.Controls.TaskDialog"/> |
||||||
|
/// </summary> |
||||||
|
public abstract class TaskDialogViewModelBase : ViewModelBase |
||||||
|
{ |
||||||
|
private TaskDialog? dialog; |
||||||
|
|
||||||
|
protected static TaskDialogCommand GetCommandButton(string text, ICommand command) |
||||||
|
{ |
||||||
|
return new TaskDialogCommand |
||||||
|
{ |
||||||
|
Text = text, |
||||||
|
DialogResult = TaskDialogStandardResult.None, |
||||||
|
Command = command, |
||||||
|
IsDefault = true, |
||||||
|
ClosesOnInvoked = false |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
protected static TaskDialogButton GetCloseButton() |
||||||
|
{ |
||||||
|
return new TaskDialogButton |
||||||
|
{ |
||||||
|
Text = Resources.Action_Close, |
||||||
|
DialogResult = TaskDialogStandardResult.Close |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Return a <see cref="TaskDialog"/> that uses this view model as its content |
||||||
|
/// </summary> |
||||||
|
public virtual TaskDialog GetDialog() |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.VerifyAccess(); |
||||||
|
|
||||||
|
dialog = new TaskDialog |
||||||
|
{ |
||||||
|
Content = this, |
||||||
|
XamlRoot = App.VisualRoot, |
||||||
|
Buttons = { GetCloseButton() } |
||||||
|
}; |
||||||
|
|
||||||
|
dialog.AttachedToVisualTree += (s, _) => |
||||||
|
{ |
||||||
|
((TaskDialog)s!).Closing += OnDialogClosing; |
||||||
|
}; |
||||||
|
dialog.DetachedFromVisualTree += (s, _) => |
||||||
|
{ |
||||||
|
((TaskDialog)s!).Closing -= OnDialogClosing; |
||||||
|
}; |
||||||
|
|
||||||
|
return dialog; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Show the dialog from <see cref="GetDialog"/> and return the result |
||||||
|
/// </summary> |
||||||
|
public async Task<TaskDialogStandardResult> ShowDialogAsync() |
||||||
|
{ |
||||||
|
return (TaskDialogStandardResult)await GetDialog().ShowAsync(true); |
||||||
|
} |
||||||
|
|
||||||
|
protected void CloseDialog(TaskDialogStandardResult result) |
||||||
|
{ |
||||||
|
dialog?.Hide(result); |
||||||
|
} |
||||||
|
|
||||||
|
protected virtual async void OnDialogClosing(object? sender, TaskDialogClosingEventArgs e) { } |
||||||
|
} |
@ -0,0 +1,141 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.ComponentModel.DataAnnotations; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using Refit; |
||||||
|
using StabilityMatrix.Avalonia.Languages; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Exceptions; |
||||||
|
using StabilityMatrix.Core.Validators; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
[View(typeof(LykosLoginDialog))] |
||||||
|
[Transient, ManagedService] |
||||||
|
public partial class LykosLoginViewModel : TaskDialogViewModelBase |
||||||
|
{ |
||||||
|
private readonly IAccountsService accountsService; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))] |
||||||
|
private bool isSignupMode; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))] |
||||||
|
[EmailAddress(ErrorMessage = "Email is not valid")] |
||||||
|
private string? email; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))] |
||||||
|
[Required] |
||||||
|
private string? username; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))] |
||||||
|
[Required] |
||||||
|
private string? password; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))] |
||||||
|
[Required, RequiresMatch<string>(nameof(Password))] |
||||||
|
private string? confirmPassword; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private AppException? loginError; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private AppException? signupError; |
||||||
|
|
||||||
|
public string SignupFooterMarkdown { get; } = |
||||||
|
"""
|
||||||
|
By signing up, you are creating a |
||||||
|
[lykos.ai](https://lykos.ai) Account and agree to our |
||||||
|
[Terms](https://lykos.ai/terms-and-conditions) and |
||||||
|
[Privacy Policy](https://lykos.ai/privacy) |
||||||
|
""";
|
||||||
|
|
||||||
|
public LykosLoginViewModel(IAccountsService accountsService) |
||||||
|
{ |
||||||
|
this.accountsService = accountsService; |
||||||
|
} |
||||||
|
|
||||||
|
private bool CanExecuteContinueButtonClick() |
||||||
|
{ |
||||||
|
return !HasErrors && IsValid(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanExecuteContinueButtonClick))] |
||||||
|
private Task OnContinueButtonClick() |
||||||
|
{ |
||||||
|
return IsSignupMode ? SignupAsync() : LoginAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task LoginAsync() |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
await accountsService.LykosLoginAsync(Email!, Password!); |
||||||
|
|
||||||
|
CloseDialog(TaskDialogStandardResult.OK); |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
LoginError = new AppException("Request timed out", "Please try again later"); |
||||||
|
} |
||||||
|
catch (ApiException e) |
||||||
|
{ |
||||||
|
LoginError = new AppException("Failed to login", $"{e.StatusCode} - {e.Message}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task SignupAsync() |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
await accountsService.LykosSignupAsync(Email!, Password!, Username!); |
||||||
|
|
||||||
|
CloseDialog(TaskDialogStandardResult.OK); |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
SignupError = new AppException("Request timed out", "Please try again later"); |
||||||
|
} |
||||||
|
catch (ApiException e) |
||||||
|
{ |
||||||
|
SignupError = new AppException("Failed to signup", $"{e.StatusCode} - {e.Message}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override TaskDialog GetDialog() |
||||||
|
{ |
||||||
|
var dialog = base.GetDialog(); |
||||||
|
dialog.Buttons = new List<TaskDialogButton> |
||||||
|
{ |
||||||
|
GetCommandButton(Resources.Action_Continue, ContinueButtonClickCommand), |
||||||
|
GetCloseButton() |
||||||
|
}; |
||||||
|
return dialog; |
||||||
|
} |
||||||
|
|
||||||
|
private bool IsValid() |
||||||
|
{ |
||||||
|
if (IsSignupMode) |
||||||
|
{ |
||||||
|
return !( |
||||||
|
string.IsNullOrEmpty(Email) |
||||||
|
|| string.IsNullOrEmpty(Username) |
||||||
|
|| string.IsNullOrEmpty(Password) |
||||||
|
|| string.IsNullOrEmpty(ConfirmPassword) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return !(string.IsNullOrEmpty(Email) || string.IsNullOrEmpty(Password)); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,84 @@ |
|||||||
|
using System; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using MessagePipe; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Helpers; |
||||||
|
using StabilityMatrix.Avalonia.Languages; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
|
||||||
|
[View(typeof(OAuthConnectDialog))] |
||||||
|
[Transient, ManagedService] |
||||||
|
public partial class OAuthConnectViewModel : ContentDialogViewModelBase |
||||||
|
{ |
||||||
|
private readonly ILogger<OAuthConnectViewModel> logger; |
||||||
|
private readonly IDistributedSubscriber<string, Uri> uriHandlerSubscriber; |
||||||
|
|
||||||
|
private IAsyncDisposable? uriHandlerSubscription; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? title = "Connect OAuth"; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? url; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? description = |
||||||
|
"Please login and click 'Allow' in the opened browser window to connect with StabilityMatrix.\n\n" |
||||||
|
+ "Once you have done so, close this prompt to complete the connection."; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? footer = "Once you have done so, close this prompt to complete the connection."; |
||||||
|
|
||||||
|
public OAuthConnectViewModel( |
||||||
|
ILogger<OAuthConnectViewModel> logger, |
||||||
|
IDistributedSubscriber<string, Uri> uriHandlerSubscriber |
||||||
|
) |
||||||
|
{ |
||||||
|
this.logger = logger; |
||||||
|
this.uriHandlerSubscriber = uriHandlerSubscriber; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
await base.OnLoadedAsync(); |
||||||
|
|
||||||
|
uriHandlerSubscription = await uriHandlerSubscriber.SubscribeAsync( |
||||||
|
UriHandler.IpcKeySend, |
||||||
|
receivedUri => |
||||||
|
{ |
||||||
|
logger.LogDebug("UriHandler Received URI: {Uri}", receivedUri.PathAndQuery); |
||||||
|
if (receivedUri.PathAndQuery.StartsWith("/oauth/patreon/callback")) |
||||||
|
{ |
||||||
|
OnPrimaryButtonClick(); |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override async Task OnUnloadedAsync() |
||||||
|
{ |
||||||
|
if (uriHandlerSubscription is not null) |
||||||
|
{ |
||||||
|
await uriHandlerSubscription.DisposeAsync(); |
||||||
|
uriHandlerSubscription = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public BetterContentDialog GetDialog() |
||||||
|
{ |
||||||
|
return new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = Title, |
||||||
|
Content = this, |
||||||
|
CloseButtonText = Resources.Action_Close |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,286 @@ |
|||||||
|
using System; |
||||||
|
using System.ComponentModel.DataAnnotations; |
||||||
|
using System.Security.Cryptography; |
||||||
|
using System.Text; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Languages; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
using StabilityMatrix.Core.Api; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Api.Lykos; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
using TeachingTip = StabilityMatrix.Core.Models.Settings.TeachingTip; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Settings; |
||||||
|
|
||||||
|
[View(typeof(AccountSettingsPage))] |
||||||
|
[Singleton, ManagedService] |
||||||
|
public partial class AccountSettingsViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private readonly IAccountsService accountsService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly ServiceManager<ViewModelBase> vmFactory; |
||||||
|
private readonly INotificationService notificationService; |
||||||
|
private readonly ILykosAuthApi lykosAuthApi; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override string Title => "Accounts"; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override IconSource IconSource => |
||||||
|
new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyCanExecuteChangedFor(nameof(ConnectLykosCommand))] |
||||||
|
[NotifyCanExecuteChangedFor(nameof(ConnectPatreonCommand))] |
||||||
|
[NotifyCanExecuteChangedFor(nameof(ConnectCivitCommand))] |
||||||
|
private bool isInitialUpdateFinished; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? lykosProfileImageUrl; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isPatreonConnected; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))] |
||||||
|
private LykosAccountStatusUpdateEventArgs lykosStatus = |
||||||
|
LykosAccountStatusUpdateEventArgs.Disconnected; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private CivitAccountStatusUpdateEventArgs civitStatus = |
||||||
|
CivitAccountStatusUpdateEventArgs.Disconnected; |
||||||
|
|
||||||
|
public AccountSettingsViewModel( |
||||||
|
IAccountsService accountsService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
ServiceManager<ViewModelBase> vmFactory, |
||||||
|
INotificationService notificationService, |
||||||
|
ILykosAuthApi lykosAuthApi |
||||||
|
) |
||||||
|
{ |
||||||
|
this.accountsService = accountsService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.vmFactory = vmFactory; |
||||||
|
this.notificationService = notificationService; |
||||||
|
this.lykosAuthApi = lykosAuthApi; |
||||||
|
|
||||||
|
accountsService.LykosAccountStatusUpdate += (_, args) => |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.Post(() => |
||||||
|
{ |
||||||
|
IsInitialUpdateFinished = true; |
||||||
|
LykosStatus = args; |
||||||
|
IsPatreonConnected = args.IsPatreonConnected; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
accountsService.CivitAccountStatusUpdate += (_, args) => |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.Post(() => |
||||||
|
{ |
||||||
|
IsInitialUpdateFinished = true; |
||||||
|
CivitStatus = args; |
||||||
|
}); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
base.OnLoaded(); |
||||||
|
|
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
accountsService.RefreshAsync().SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<bool> BeforeConnectCheck() |
||||||
|
{ |
||||||
|
// Show credentials storage notice if not seen |
||||||
|
if ( |
||||||
|
!settingsManager.Settings.SeenTeachingTips.Contains( |
||||||
|
TeachingTip.AccountsCredentialsStorageNotice |
||||||
|
) |
||||||
|
) |
||||||
|
{ |
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = "About Account Credentials", |
||||||
|
Content = """
|
||||||
|
Account credentials and tokens are stored locally on your computer, with at-rest AES encryption. |
||||||
|
|
||||||
|
If you make changes to your computer hardware, you may need to re-login to your accounts. |
||||||
|
|
||||||
|
Account tokens will not be viewable after saving, please make a note of them if you need to use them elsewhere. |
||||||
|
""",
|
||||||
|
PrimaryButtonText = Resources.Action_Continue, |
||||||
|
CloseButtonText = Resources.Action_Cancel, |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
MaxDialogWidth = 400 |
||||||
|
}; |
||||||
|
|
||||||
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
settingsManager.Transaction( |
||||||
|
s => s.SeenTeachingTips.Add(TeachingTip.AccountsCredentialsStorageNotice) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))] |
||||||
|
private async Task ConnectLykos() |
||||||
|
{ |
||||||
|
if (!await BeforeConnectCheck()) |
||||||
|
return; |
||||||
|
|
||||||
|
var vm = vmFactory.Get<LykosLoginViewModel>(); |
||||||
|
await vm.ShowDialogAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private Task DisconnectLykos() |
||||||
|
{ |
||||||
|
return accountsService.LykosLogoutAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))] |
||||||
|
private async Task ConnectPatreon() |
||||||
|
{ |
||||||
|
if (!await BeforeConnectCheck()) |
||||||
|
return; |
||||||
|
|
||||||
|
if (LykosStatus.User?.Id is null) |
||||||
|
return; |
||||||
|
|
||||||
|
var urlResult = await notificationService.TryAsync( |
||||||
|
lykosAuthApi.GetPatreonOAuthUrl( |
||||||
|
Program.MessagePipeUri.Append("/oauth/patreon/callback").ToString() |
||||||
|
) |
||||||
|
); |
||||||
|
|
||||||
|
if (!urlResult.IsSuccessful || urlResult.Result is not { } url) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
ProcessRunner.OpenUrl(urlResult.Result); |
||||||
|
|
||||||
|
var dialogVm = vmFactory.Get<OAuthConnectViewModel>(); |
||||||
|
dialogVm.Title = "Connect Patreon Account"; |
||||||
|
dialogVm.Url = url; |
||||||
|
|
||||||
|
if (await dialogVm.GetDialog().ShowAsync() == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
await accountsService.RefreshAsync(); |
||||||
|
|
||||||
|
// Bring main window to front since browser is probably covering |
||||||
|
var main = App.TopLevel as Window; |
||||||
|
main?.Activate(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DisconnectPatreon() |
||||||
|
{ |
||||||
|
await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync()); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))] |
||||||
|
private async Task ConnectCivit() |
||||||
|
{ |
||||||
|
if (!await BeforeConnectCheck()) |
||||||
|
return; |
||||||
|
|
||||||
|
var textFields = new TextBoxField[] |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
Label = Resources.Label_ApiKey, |
||||||
|
Validator = s => |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(s)) |
||||||
|
{ |
||||||
|
throw new ValidationException("API key is required"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
var dialog = DialogHelper.CreateTextEntryDialog( |
||||||
|
"Connect CivitAI Account", |
||||||
|
"""
|
||||||
|
Login to [CivitAI](https://civitai.com/) and head to your [Account](https://civitai.com/user/account) page |
||||||
|
|
||||||
|
Add a new API key and paste it below |
||||||
|
""",
|
||||||
|
"avares://StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp", |
||||||
|
textFields |
||||||
|
); |
||||||
|
dialog.PrimaryButtonText = Resources.Action_Connect; |
||||||
|
|
||||||
|
if ( |
||||||
|
await dialog.ShowAsync() != ContentDialogResult.Primary |
||||||
|
|| textFields[0].Text is not { } apiToken |
||||||
|
) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var result = await notificationService.TryAsync(accountsService.CivitLoginAsync(apiToken)); |
||||||
|
|
||||||
|
if (result.IsSuccessful) |
||||||
|
{ |
||||||
|
await accountsService.RefreshAsync(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private Task DisconnectCivit() |
||||||
|
{ |
||||||
|
return accountsService.CivitLogoutAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Update the Lykos profile image URL when the user changes. |
||||||
|
/// </summary> |
||||||
|
partial void OnLykosStatusChanged(LykosAccountStatusUpdateEventArgs? value) |
||||||
|
{ |
||||||
|
if (value?.User?.Id is { } userEmail) |
||||||
|
{ |
||||||
|
userEmail = userEmail.Trim().ToLowerInvariant(); |
||||||
|
|
||||||
|
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(userEmail)); |
||||||
|
var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); |
||||||
|
|
||||||
|
LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro"; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
LykosProfileImageUrl = null; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -1,9 +1,20 @@ |
|||||||
using StabilityMatrix.Avalonia.ViewModels.Base; |
using FluentAvalonia.UI.Controls; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
using StabilityMatrix.Avalonia.Views.Settings; |
using StabilityMatrix.Avalonia.Views.Settings; |
||||||
using StabilityMatrix.Core.Attributes; |
using StabilityMatrix.Core.Attributes; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
namespace StabilityMatrix.Avalonia.ViewModels.Settings; |
namespace StabilityMatrix.Avalonia.ViewModels.Settings; |
||||||
|
|
||||||
[View(typeof(InferenceSettingsPage))] |
[View(typeof(InferenceSettingsPage))] |
||||||
[Singleton] |
[Singleton, ManagedService] |
||||||
public partial class InferenceSettingsViewModel : ViewModelBase { } |
public class InferenceSettingsViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
/// <inheritdoc /> |
||||||
|
public override string Title => "Inference"; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override IconSource IconSource => |
||||||
|
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; |
||||||
|
} |
||||||
|
@ -0,0 +1,960 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.Immutable; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.ComponentModel; |
||||||
|
using System.ComponentModel.DataAnnotations; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Globalization; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Reactive.Linq; |
||||||
|
using System.Reflection; |
||||||
|
using System.Text; |
||||||
|
using System.Text.Json; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls.Notifications; |
||||||
|
using Avalonia.Controls.Primitives; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Platform.Storage; |
||||||
|
using Avalonia.Styling; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using DynamicData.Binding; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using SkiaSharp; |
||||||
|
using StabilityMatrix.Avalonia.Animations; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Extensions; |
||||||
|
using StabilityMatrix.Avalonia.Helpers; |
||||||
|
using StabilityMatrix.Avalonia.Languages; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Avalonia.Models.Inference; |
||||||
|
using StabilityMatrix.Avalonia.Models.TagCompletion; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Inference; |
||||||
|
using StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
using StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Python; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Settings; |
||||||
|
|
||||||
|
[View(typeof(MainSettingsPage))] |
||||||
|
[Singleton, ManagedService] |
||||||
|
public partial class MainSettingsViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
private readonly INotificationService notificationService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly IPrerequisiteHelper prerequisiteHelper; |
||||||
|
private readonly IPyRunner pyRunner; |
||||||
|
private readonly ServiceManager<ViewModelBase> dialogFactory; |
||||||
|
private readonly ICompletionProvider completionProvider; |
||||||
|
private readonly ITrackedDownloadService trackedDownloadService; |
||||||
|
private readonly IModelIndexService modelIndexService; |
||||||
|
private readonly INavigationService<SettingsViewModel> settingsNavigationService; |
||||||
|
private readonly IAccountsService accountsService; |
||||||
|
|
||||||
|
public SharedState SharedState { get; } |
||||||
|
|
||||||
|
public override string Title => "Settings"; |
||||||
|
public override IconSource IconSource => |
||||||
|
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; |
||||||
|
|
||||||
|
// ReSharper disable once MemberCanBeMadeStatic.Global |
||||||
|
public string AppVersion => |
||||||
|
$"Version {Compat.AppVersion.ToDisplayString()}" + (Program.IsDebugBuild ? " (Debug)" : ""); |
||||||
|
|
||||||
|
// Theme section |
||||||
|
[ObservableProperty] |
||||||
|
private string? selectedTheme; |
||||||
|
|
||||||
|
public IReadOnlyList<string> AvailableThemes { get; } = new[] { "Light", "Dark", "System", }; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private CultureInfo selectedLanguage; |
||||||
|
|
||||||
|
// ReSharper disable once MemberCanBeMadeStatic.Global |
||||||
|
public IReadOnlyList<CultureInfo> AvailableLanguages => Cultures.SupportedCultures; |
||||||
|
|
||||||
|
public IReadOnlyList<float> AnimationScaleOptions { get; } = |
||||||
|
new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, }; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private float selectedAnimationScale; |
||||||
|
|
||||||
|
// Shared folder options |
||||||
|
[ObservableProperty] |
||||||
|
private bool removeSymlinksOnShutdown; |
||||||
|
|
||||||
|
// Inference UI section |
||||||
|
[ObservableProperty] |
||||||
|
private bool isPromptCompletionEnabled = true; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private IReadOnlyList<string> availableTagCompletionCsvs = Array.Empty<string>(); |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? selectedTagCompletionCsv; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isCompletionRemoveUnderscoresEnabled = true; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[CustomValidation(typeof(MainSettingsViewModel), nameof(ValidateOutputImageFileNameFormat))] |
||||||
|
private string? outputImageFileNameFormat; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? outputImageFileNameFormatSample; |
||||||
|
|
||||||
|
public IEnumerable<FileNameFormatVar> OutputImageFileNameFormatVars => |
||||||
|
FileNameFormatProvider |
||||||
|
.GetSample() |
||||||
|
.Substitutions.Select( |
||||||
|
kv => |
||||||
|
new FileNameFormatVar |
||||||
|
{ |
||||||
|
Variable = $"{{{kv.Key}}}", |
||||||
|
Example = kv.Value.Invoke() |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isImageViewerPixelGridEnabled = true; |
||||||
|
|
||||||
|
// Integrations section |
||||||
|
[ObservableProperty] |
||||||
|
private bool isDiscordRichPresenceEnabled; |
||||||
|
|
||||||
|
// Debug section |
||||||
|
[ObservableProperty] |
||||||
|
private string? debugPaths; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? debugCompatInfo; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private string? debugGpuInfo; |
||||||
|
|
||||||
|
// Info section |
||||||
|
private const int VersionTapCountThreshold = 7; |
||||||
|
|
||||||
|
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))] |
||||||
|
private int versionTapCount; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isVersionTapTeachingTipOpen; |
||||||
|
public string VersionFlyoutText => |
||||||
|
$"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options."; |
||||||
|
|
||||||
|
public string DataDirectory => |
||||||
|
settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set"; |
||||||
|
|
||||||
|
public MainSettingsViewModel( |
||||||
|
INotificationService notificationService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IPrerequisiteHelper prerequisiteHelper, |
||||||
|
IPyRunner pyRunner, |
||||||
|
ServiceManager<ViewModelBase> dialogFactory, |
||||||
|
ITrackedDownloadService trackedDownloadService, |
||||||
|
SharedState sharedState, |
||||||
|
ICompletionProvider completionProvider, |
||||||
|
IModelIndexService modelIndexService, |
||||||
|
INavigationService<SettingsViewModel> settingsNavigationService, |
||||||
|
IAccountsService accountsService |
||||||
|
) |
||||||
|
{ |
||||||
|
this.notificationService = notificationService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.prerequisiteHelper = prerequisiteHelper; |
||||||
|
this.pyRunner = pyRunner; |
||||||
|
this.dialogFactory = dialogFactory; |
||||||
|
this.trackedDownloadService = trackedDownloadService; |
||||||
|
this.completionProvider = completionProvider; |
||||||
|
this.modelIndexService = modelIndexService; |
||||||
|
this.settingsNavigationService = settingsNavigationService; |
||||||
|
this.accountsService = accountsService; |
||||||
|
|
||||||
|
SharedState = sharedState; |
||||||
|
|
||||||
|
if (Program.Args.DebugMode) |
||||||
|
{ |
||||||
|
SharedState.IsDebugMode = true; |
||||||
|
} |
||||||
|
|
||||||
|
SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; |
||||||
|
SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language); |
||||||
|
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; |
||||||
|
SelectedAnimationScale = settingsManager.Settings.AnimationScale; |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.IsDiscordRichPresenceEnabled, |
||||||
|
settings => settings.IsDiscordRichPresenceEnabled, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.SelectedAnimationScale, |
||||||
|
settings => settings.AnimationScale |
||||||
|
); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.SelectedTagCompletionCsv, |
||||||
|
settings => settings.TagCompletionCsv |
||||||
|
); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.IsPromptCompletionEnabled, |
||||||
|
settings => settings.IsPromptCompletionEnabled, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.IsCompletionRemoveUnderscoresEnabled, |
||||||
|
settings => settings.IsCompletionRemoveUnderscoresEnabled, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat) |
||||||
|
.Throttle(TimeSpan.FromMilliseconds(50)) |
||||||
|
.Subscribe(formatProperty => |
||||||
|
{ |
||||||
|
var provider = FileNameFormatProvider.GetSample(); |
||||||
|
var template = formatProperty.Value ?? string.Empty; |
||||||
|
|
||||||
|
if ( |
||||||
|
!string.IsNullOrEmpty(template) |
||||||
|
&& provider.Validate(template) == ValidationResult.Success |
||||||
|
) |
||||||
|
{ |
||||||
|
var format = FileNameFormat.Parse(template, provider); |
||||||
|
OutputImageFileNameFormatSample = format.GetFileName() + ".png"; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// Use default format if empty |
||||||
|
var defaultFormat = FileNameFormat.Parse( |
||||||
|
FileNameFormat.DefaultTemplate, |
||||||
|
provider |
||||||
|
); |
||||||
|
OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png"; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.OutputImageFileNameFormat, |
||||||
|
settings => settings.InferenceOutputImageFileNameFormat, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.IsImageViewerPixelGridEnabled, |
||||||
|
settings => settings.IsImageViewerPixelGridEnabled, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler( |
||||||
|
notificationService, |
||||||
|
LogLevel.Warn |
||||||
|
); |
||||||
|
ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
await base.OnLoadedAsync(); |
||||||
|
|
||||||
|
await notificationService.TryAsync(completionProvider.Setup()); |
||||||
|
|
||||||
|
UpdateAvailableTagCompletionCsvs(); |
||||||
|
|
||||||
|
// Start accounts update |
||||||
|
accountsService.RefreshAsync().SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
public static ValidationResult ValidateOutputImageFileNameFormat( |
||||||
|
string? format, |
||||||
|
ValidationContext context |
||||||
|
) |
||||||
|
{ |
||||||
|
return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedThemeChanged(string? value) |
||||||
|
{ |
||||||
|
// In case design / tests |
||||||
|
if (Application.Current is null) |
||||||
|
return; |
||||||
|
// Change theme |
||||||
|
Application.Current.RequestedThemeVariant = value switch |
||||||
|
{ |
||||||
|
"Dark" => ThemeVariant.Dark, |
||||||
|
"Light" => ThemeVariant.Light, |
||||||
|
_ => ThemeVariant.Default |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue) |
||||||
|
{ |
||||||
|
if (oldValue is null || newValue.Name == Cultures.Current?.Name) |
||||||
|
return; |
||||||
|
|
||||||
|
// Set locale |
||||||
|
if (AvailableLanguages.Contains(newValue)) |
||||||
|
{ |
||||||
|
Logger.Info("Changing language from {Old} to {New}", oldValue, newValue); |
||||||
|
|
||||||
|
Cultures.TrySetSupportedCulture(newValue); |
||||||
|
settingsManager.Transaction(s => s.Language = newValue.Name); |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = Resources.Label_RelaunchRequired, |
||||||
|
Content = Resources.Text_RelaunchRequiredToApplyLanguage, |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
PrimaryButtonText = Resources.Action_Relaunch, |
||||||
|
CloseButtonText = Resources.Action_RelaunchLater |
||||||
|
}; |
||||||
|
|
||||||
|
Dispatcher.UIThread.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
if (await dialog.ShowAsync() == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
Process.Start(Compat.AppCurrentPath); |
||||||
|
App.Shutdown(); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
Logger.Info( |
||||||
|
"Requested invalid language change from {Old} to {New}", |
||||||
|
oldValue, |
||||||
|
newValue |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnRemoveSymlinksOnShutdownChanged(bool value) |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task ResetCheckpointCache() |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet<string>()); |
||||||
|
await Task.Run(() => settingsManager.IndexCheckpoints()); |
||||||
|
notificationService.Show( |
||||||
|
"Checkpoint cache reset", |
||||||
|
"The checkpoint cache has been reset.", |
||||||
|
NotificationType.Success |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private void NavigateToSubPage(Type viewModelType) |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.Post( |
||||||
|
() => |
||||||
|
settingsNavigationService.NavigateTo( |
||||||
|
viewModelType, |
||||||
|
BetterSlideNavigationTransition.PageSlideFromRight |
||||||
|
), |
||||||
|
DispatcherPriority.Send |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
#region Package Environment |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task OpenEnvVarsDialog() |
||||||
|
{ |
||||||
|
var viewModel = dialogFactory.Get<EnvVarsViewModel>(); |
||||||
|
|
||||||
|
// Load current settings |
||||||
|
var current = |
||||||
|
settingsManager.Settings.EnvironmentVariables ?? new Dictionary<string, string>(); |
||||||
|
viewModel.EnvVars = new ObservableCollection<EnvVarKeyPair>( |
||||||
|
current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value)) |
||||||
|
); |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Content = new EnvVarsDialog { DataContext = viewModel }, |
||||||
|
PrimaryButtonText = Resources.Action_Save, |
||||||
|
IsPrimaryButtonEnabled = true, |
||||||
|
CloseButtonText = Resources.Action_Cancel, |
||||||
|
}; |
||||||
|
|
||||||
|
if (await dialog.ShowAsync() == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
// Save settings |
||||||
|
var newEnvVars = viewModel.EnvVars |
||||||
|
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) |
||||||
|
.GroupBy(kvp => kvp.Key, StringComparer.Ordinal) |
||||||
|
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal); |
||||||
|
settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task CheckPythonVersion() |
||||||
|
{ |
||||||
|
var isInstalled = prerequisiteHelper.IsPythonInstalled; |
||||||
|
Logger.Debug($"Check python installed: {isInstalled}"); |
||||||
|
// Ensure python installed |
||||||
|
if (!prerequisiteHelper.IsPythonInstalled) |
||||||
|
{ |
||||||
|
// Need 7z as well for site packages repack |
||||||
|
Logger.Debug("Python not installed, unpacking resources..."); |
||||||
|
await prerequisiteHelper.UnpackResourcesIfNecessary(); |
||||||
|
Logger.Debug("Unpacked resources, installing python..."); |
||||||
|
await prerequisiteHelper.InstallPythonIfNecessary(); |
||||||
|
} |
||||||
|
|
||||||
|
// Get python version |
||||||
|
await pyRunner.Initialize(); |
||||||
|
var result = await pyRunner.GetVersionInfo(); |
||||||
|
// Show dialog box |
||||||
|
var dialog = new ContentDialog |
||||||
|
{ |
||||||
|
Title = Resources.Label_PythonVersionInfo, |
||||||
|
Content = result, |
||||||
|
PrimaryButtonText = Resources.Action_OK, |
||||||
|
IsPrimaryButtonEnabled = true |
||||||
|
}; |
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
#endregion |
||||||
|
|
||||||
|
#region Inference UI |
||||||
|
|
||||||
|
private void UpdateAvailableTagCompletionCsvs() |
||||||
|
{ |
||||||
|
if (!settingsManager.IsLibraryDirSet) |
||||||
|
return; |
||||||
|
|
||||||
|
var tagsDir = settingsManager.TagsDirectory; |
||||||
|
if (!tagsDir.Exists) |
||||||
|
return; |
||||||
|
|
||||||
|
var csvFiles = tagsDir.Info.EnumerateFiles("*.csv"); |
||||||
|
AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray(); |
||||||
|
|
||||||
|
// Set selected to current if exists |
||||||
|
var settingsCsv = settingsManager.Settings.TagCompletionCsv; |
||||||
|
if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv)) |
||||||
|
{ |
||||||
|
SelectedTagCompletionCsv = settingsCsv; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand(FlowExceptionsToTaskScheduler = true)] |
||||||
|
private async Task ImportTagCsv() |
||||||
|
{ |
||||||
|
var storage = App.StorageProvider; |
||||||
|
var files = await storage.OpenFilePickerAsync( |
||||||
|
new FilePickerOpenOptions |
||||||
|
{ |
||||||
|
FileTypeFilter = new List<FilePickerFileType> |
||||||
|
{ |
||||||
|
new("CSV") { Patterns = new[] { "*.csv" }, } |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (files.Count == 0) |
||||||
|
return; |
||||||
|
|
||||||
|
var sourceFile = new FilePath(files[0].TryGetLocalPath()!); |
||||||
|
|
||||||
|
var tagsDir = settingsManager.TagsDirectory; |
||||||
|
tagsDir.Create(); |
||||||
|
|
||||||
|
// Copy to tags directory |
||||||
|
var targetFile = tagsDir.JoinFile(sourceFile.Name); |
||||||
|
await sourceFile.CopyToAsync(targetFile); |
||||||
|
|
||||||
|
// Update index |
||||||
|
UpdateAvailableTagCompletionCsvs(); |
||||||
|
|
||||||
|
// Trigger load |
||||||
|
completionProvider.BackgroundLoadFromFile(targetFile, true); |
||||||
|
|
||||||
|
notificationService.Show( |
||||||
|
$"Imported {sourceFile.Name}", |
||||||
|
$"The {sourceFile.Name} file has been imported.", |
||||||
|
NotificationType.Success |
||||||
|
); |
||||||
|
} |
||||||
|
#endregion |
||||||
|
|
||||||
|
#region System |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Adds Stability Matrix to Start Menu for the current user. |
||||||
|
/// </summary> |
||||||
|
[RelayCommand] |
||||||
|
private async Task AddToStartMenu() |
||||||
|
{ |
||||||
|
if (!Compat.IsWindows) |
||||||
|
{ |
||||||
|
notificationService.Show("Not supported", "This feature is only supported on Windows."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await using var _ = new MinimumDelay(200, 300); |
||||||
|
|
||||||
|
var shortcutDir = new DirectoryPath( |
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), |
||||||
|
"Programs" |
||||||
|
); |
||||||
|
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); |
||||||
|
|
||||||
|
var appPath = Compat.AppCurrentPath; |
||||||
|
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); |
||||||
|
await Assets.AppIcon.ExtractTo(iconPath); |
||||||
|
|
||||||
|
WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix"); |
||||||
|
|
||||||
|
notificationService.Show( |
||||||
|
"Added to Start Menu", |
||||||
|
"Stability Matrix has been added to the Start Menu.", |
||||||
|
NotificationType.Success |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Add Stability Matrix to Start Menu for all users. |
||||||
|
/// <remarks>Requires Admin elevation.</remarks> |
||||||
|
/// </summary> |
||||||
|
[RelayCommand] |
||||||
|
private async Task AddToGlobalStartMenu() |
||||||
|
{ |
||||||
|
if (!Compat.IsWindows) |
||||||
|
{ |
||||||
|
notificationService.Show("Not supported", "This feature is only supported on Windows."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Confirmation dialog |
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = |
||||||
|
"This will create a shortcut for Stability Matrix in the Start Menu for all users", |
||||||
|
Content = "You will be prompted for administrator privileges. Continue?", |
||||||
|
PrimaryButtonText = Resources.Action_Yes, |
||||||
|
CloseButtonText = Resources.Action_Cancel, |
||||||
|
DefaultButton = ContentDialogButton.Primary |
||||||
|
}; |
||||||
|
|
||||||
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await using var _ = new MinimumDelay(200, 300); |
||||||
|
|
||||||
|
var shortcutDir = new DirectoryPath( |
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), |
||||||
|
"Programs" |
||||||
|
); |
||||||
|
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); |
||||||
|
|
||||||
|
var appPath = Compat.AppCurrentPath; |
||||||
|
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); |
||||||
|
|
||||||
|
// We can't directly write to the targets, so extract to temporary directory first |
||||||
|
using var tempDir = new TempDirectoryPath(); |
||||||
|
|
||||||
|
await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico")); |
||||||
|
WindowsShortcuts.CreateShortcut( |
||||||
|
tempDir.JoinFile("Stability Matrix.lnk"), |
||||||
|
appPath, |
||||||
|
iconPath, |
||||||
|
"Stability Matrix" |
||||||
|
); |
||||||
|
|
||||||
|
// Move to target |
||||||
|
try |
||||||
|
{ |
||||||
|
var moveLinkResult = await WindowsElevated.MoveFiles( |
||||||
|
(tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink), |
||||||
|
(tempDir.JoinFile("Stability Matrix.ico"), iconPath) |
||||||
|
); |
||||||
|
if (moveLinkResult != 0) |
||||||
|
{ |
||||||
|
notificationService.ShowPersistent( |
||||||
|
"Failed to create shortcut", |
||||||
|
$"Could not copy shortcut", |
||||||
|
NotificationType.Error |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Win32Exception e) |
||||||
|
{ |
||||||
|
// We'll get this exception if user cancels UAC |
||||||
|
Logger.Warn(e, "Could not create shortcut"); |
||||||
|
notificationService.Show("Could not create shortcut", "", NotificationType.Warning); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
notificationService.Show( |
||||||
|
"Added to Start Menu", |
||||||
|
"Stability Matrix has been added to the Start Menu for all users.", |
||||||
|
NotificationType.Success |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task PickNewDataDirectory() |
||||||
|
{ |
||||||
|
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>(); |
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
IsPrimaryButtonEnabled = false, |
||||||
|
IsSecondaryButtonEnabled = false, |
||||||
|
IsFooterVisible = false, |
||||||
|
Content = new SelectDataDirectoryDialog { DataContext = viewModel } |
||||||
|
}; |
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
if (result == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
// 1. For portable mode, call settings.SetPortableMode() |
||||||
|
if (viewModel.IsPortableMode) |
||||||
|
{ |
||||||
|
settingsManager.SetPortableMode(); |
||||||
|
} |
||||||
|
// 2. For custom path, call settings.SetLibraryPath(path) |
||||||
|
else |
||||||
|
{ |
||||||
|
settingsManager.SetLibraryPath(viewModel.DataDirectory); |
||||||
|
} |
||||||
|
|
||||||
|
// Restart |
||||||
|
var restartDialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
Title = "Restart required", |
||||||
|
Content = "Stability Matrix must be restarted for the changes to take effect.", |
||||||
|
PrimaryButtonText = Resources.Action_Restart, |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
IsSecondaryButtonEnabled = false, |
||||||
|
}; |
||||||
|
await restartDialog.ShowAsync(); |
||||||
|
|
||||||
|
Process.Start(Compat.AppCurrentPath); |
||||||
|
App.Shutdown(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#endregion |
||||||
|
|
||||||
|
#region Debug Section |
||||||
|
public void LoadDebugInfo() |
||||||
|
{ |
||||||
|
var assembly = Assembly.GetExecutingAssembly(); |
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); |
||||||
|
DebugPaths = $"""
|
||||||
|
Current Working Directory [Environment.CurrentDirectory] |
||||||
|
"{Environment.CurrentDirectory}" |
||||||
|
App Directory [Assembly.GetExecutingAssembly().Location] |
||||||
|
"{assembly.Location}" |
||||||
|
App Directory [AppContext.BaseDirectory] |
||||||
|
"{AppContext.BaseDirectory}" |
||||||
|
AppData Directory [SpecialFolder.ApplicationData] |
||||||
|
"{appData}" |
||||||
|
""";
|
||||||
|
|
||||||
|
// 1. Check portable mode |
||||||
|
var appDir = Compat.AppCurrentDir; |
||||||
|
var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable"); |
||||||
|
var isPortableMode = File.Exists(expectedPortableFile); |
||||||
|
|
||||||
|
DebugCompatInfo = $"""
|
||||||
|
Platform: {Compat.Platform} |
||||||
|
AppData: {Compat.AppData} |
||||||
|
AppDataHome: {Compat.AppDataHome} |
||||||
|
AppCurrentDir: {Compat.AppCurrentDir} |
||||||
|
ExecutableName: {Compat.GetExecutableName()} |
||||||
|
-- Settings -- |
||||||
|
Expected Portable Marker file: {expectedPortableFile} |
||||||
|
Portable Marker file exists: {isPortableMode} |
||||||
|
IsLibraryDirSet = {settingsManager.IsLibraryDirSet} |
||||||
|
IsPortableMode = {settingsManager.IsPortableMode} |
||||||
|
""";
|
||||||
|
|
||||||
|
// Get Gpu info |
||||||
|
var gpuInfo = ""; |
||||||
|
foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate()) |
||||||
|
{ |
||||||
|
gpuInfo += $"[{i + 1}] {gpu}\n"; |
||||||
|
} |
||||||
|
DebugGpuInfo = gpuInfo; |
||||||
|
} |
||||||
|
|
||||||
|
// Debug buttons |
||||||
|
[RelayCommand] |
||||||
|
private void DebugNotification() |
||||||
|
{ |
||||||
|
notificationService.Show( |
||||||
|
new Notification( |
||||||
|
title: "Test Notification", |
||||||
|
message: "Here is some message", |
||||||
|
type: NotificationType.Information |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DebugContentDialog() |
||||||
|
{ |
||||||
|
var dialog = new ContentDialog |
||||||
|
{ |
||||||
|
DefaultButton = ContentDialogButton.Primary, |
||||||
|
Title = "Test title", |
||||||
|
PrimaryButtonText = Resources.Action_OK, |
||||||
|
CloseButtonText = Resources.Action_Close |
||||||
|
}; |
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(); |
||||||
|
notificationService.Show(new Notification("Content dialog closed", $"Result: {result}")); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private void DebugThrowException() |
||||||
|
{ |
||||||
|
throw new OperationCanceledException("Example Message"); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand(FlowExceptionsToTaskScheduler = true)] |
||||||
|
private async Task DebugThrowAsyncException() |
||||||
|
{ |
||||||
|
await Task.Yield(); |
||||||
|
|
||||||
|
throw new ApplicationException("Example Message"); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DebugMakeImageGrid() |
||||||
|
{ |
||||||
|
var provider = App.StorageProvider; |
||||||
|
var files = await provider.OpenFilePickerAsync( |
||||||
|
new FilePickerOpenOptions() { AllowMultiple = true } |
||||||
|
); |
||||||
|
|
||||||
|
if (files.Count == 0) |
||||||
|
return; |
||||||
|
|
||||||
|
var images = await files.SelectAsync( |
||||||
|
async f => SKImage.FromEncodedData(await f.OpenReadAsync()) |
||||||
|
); |
||||||
|
|
||||||
|
var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray()); |
||||||
|
|
||||||
|
// Show preview |
||||||
|
|
||||||
|
using var peekPixels = grid.PeekPixels(); |
||||||
|
using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100); |
||||||
|
await using var stream = data.AsStream(); |
||||||
|
|
||||||
|
var bitmap = WriteableBitmap.Decode(stream); |
||||||
|
|
||||||
|
var galleryImages = new List<ImageSource> { new(bitmap), }; |
||||||
|
galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString()))); |
||||||
|
|
||||||
|
var imageBox = new ImageGalleryCard |
||||||
|
{ |
||||||
|
Width = 1000, |
||||||
|
Height = 900, |
||||||
|
DataContext = dialogFactory.Get<ImageGalleryCardViewModel>(vm => |
||||||
|
{ |
||||||
|
vm.ImageSources.AddRange(galleryImages); |
||||||
|
}) |
||||||
|
}; |
||||||
|
|
||||||
|
var dialog = new BetterContentDialog |
||||||
|
{ |
||||||
|
MaxDialogWidth = 1000, |
||||||
|
MaxDialogHeight = 1000, |
||||||
|
FullSizeDesired = true, |
||||||
|
Content = imageBox, |
||||||
|
CloseButtonText = "Close", |
||||||
|
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, |
||||||
|
}; |
||||||
|
|
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DebugLoadCompletionCsv() |
||||||
|
{ |
||||||
|
var provider = App.StorageProvider; |
||||||
|
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); |
||||||
|
|
||||||
|
if (files.Count == 0) |
||||||
|
return; |
||||||
|
|
||||||
|
await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true); |
||||||
|
|
||||||
|
notificationService.Show("Loaded completion file", ""); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DebugImageMetadata() |
||||||
|
{ |
||||||
|
var provider = App.StorageProvider; |
||||||
|
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); |
||||||
|
|
||||||
|
if (files.Count == 0) |
||||||
|
return; |
||||||
|
|
||||||
|
var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!); |
||||||
|
var textualTags = metadata.GetTextualData()?.ToArray(); |
||||||
|
|
||||||
|
if (textualTags is null) |
||||||
|
{ |
||||||
|
notificationService.Show("No textual data found", ""); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (metadata.GetGenerationParameters() is { } parameters) |
||||||
|
{ |
||||||
|
var parametersJson = JsonSerializer.Serialize(parameters); |
||||||
|
var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters"); |
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DebugRefreshModelsIndex() |
||||||
|
{ |
||||||
|
await modelIndexService.RefreshIndex(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task DebugTrackedDownload() |
||||||
|
{ |
||||||
|
var textFields = new TextBoxField[] |
||||||
|
{ |
||||||
|
new() { Label = "Url", }, |
||||||
|
new() { Label = "File path" } |
||||||
|
}; |
||||||
|
|
||||||
|
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields); |
||||||
|
|
||||||
|
if (await dialog.ShowAsync() == ContentDialogResult.Primary) |
||||||
|
{ |
||||||
|
var url = textFields[0].Text; |
||||||
|
var filePath = textFields[1].Text; |
||||||
|
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath)); |
||||||
|
download.Start(); |
||||||
|
} |
||||||
|
} |
||||||
|
#endregion |
||||||
|
|
||||||
|
#region Info Section |
||||||
|
|
||||||
|
public void OnVersionClick() |
||||||
|
{ |
||||||
|
// Ignore if already enabled |
||||||
|
if (SharedState.IsDebugMode) |
||||||
|
return; |
||||||
|
|
||||||
|
VersionTapCount++; |
||||||
|
|
||||||
|
switch (VersionTapCount) |
||||||
|
{ |
||||||
|
// Reached required threshold |
||||||
|
case >= VersionTapCountThreshold: |
||||||
|
{ |
||||||
|
IsVersionTapTeachingTipOpen = false; |
||||||
|
// Enable debug options |
||||||
|
SharedState.IsDebugMode = true; |
||||||
|
notificationService.Show( |
||||||
|
"Debug options enabled", |
||||||
|
"Warning: Improper use may corrupt application state or cause loss of data." |
||||||
|
); |
||||||
|
VersionTapCount = 0; |
||||||
|
break; |
||||||
|
} |
||||||
|
// Open teaching tip above 3rd click |
||||||
|
case >= 3: |
||||||
|
IsVersionTapTeachingTipOpen = true; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task ShowLicensesDialog() |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var markdown = GetLicensesMarkdown(); |
||||||
|
|
||||||
|
var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses"); |
||||||
|
dialog.MaxDialogHeight = 600; |
||||||
|
await dialog.ShowAsync(); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
notificationService.Show( |
||||||
|
"Failed to read licenses information", |
||||||
|
$"{e}", |
||||||
|
NotificationType.Error |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static string GetLicensesMarkdown() |
||||||
|
{ |
||||||
|
// Read licenses.json |
||||||
|
using var reader = new StreamReader(Assets.LicensesJson.Open()); |
||||||
|
var licenses = |
||||||
|
JsonSerializer.Deserialize<IReadOnlyList<LicenseInfo>>(reader.ReadToEnd()) |
||||||
|
?? throw new InvalidOperationException("Failed to read licenses.json"); |
||||||
|
|
||||||
|
// Generate markdown |
||||||
|
var builder = new StringBuilder(); |
||||||
|
foreach (var license in licenses) |
||||||
|
{ |
||||||
|
builder.AppendLine( |
||||||
|
$"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}" |
||||||
|
); |
||||||
|
builder.AppendLine(); |
||||||
|
builder.AppendLine(license.Description); |
||||||
|
builder.AppendLine(); |
||||||
|
builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})"); |
||||||
|
builder.AppendLine(); |
||||||
|
} |
||||||
|
|
||||||
|
return builder.ToString(); |
||||||
|
} |
||||||
|
|
||||||
|
#endregion |
||||||
|
} |
@ -0,0 +1,236 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using Exceptionless.DateTimeExtensions; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using FluentAvalonia.UI.Media.Animation; |
||||||
|
using Semver; |
||||||
|
using StabilityMatrix.Avalonia.Languages; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Avalonia.Services; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||||
|
using StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Models.Update; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using StabilityMatrix.Core.Updater; |
||||||
|
using Symbol = FluentIcons.Common.Symbol; |
||||||
|
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels.Settings; |
||||||
|
|
||||||
|
[View(typeof(UpdateSettingsPage))] |
||||||
|
[Singleton, ManagedService] |
||||||
|
public partial class UpdateSettingsViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private readonly IUpdateHelper updateHelper; |
||||||
|
private readonly IAccountsService accountsService; |
||||||
|
private readonly INavigationService<SettingsViewModel> settingsNavService; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(IsUpdateAvailable))] |
||||||
|
[NotifyPropertyChangedFor(nameof(HeaderText))] |
||||||
|
[NotifyPropertyChangedFor(nameof(SubtitleText))] |
||||||
|
private UpdateStatusChangedEventArgs? updateStatus; |
||||||
|
|
||||||
|
public bool IsUpdateAvailable => UpdateStatus?.LatestUpdate != null; |
||||||
|
|
||||||
|
public string HeaderText => |
||||||
|
IsUpdateAvailable ? Resources.Label_UpdateAvailable : Resources.Label_YouAreUpToDate; |
||||||
|
|
||||||
|
public string? SubtitleText => |
||||||
|
UpdateStatus is null |
||||||
|
? null |
||||||
|
: string.Format( |
||||||
|
Resources.TextTemplate_LastChecked, |
||||||
|
UpdateStatus.CheckedAt.ToApproximateAgeString() |
||||||
|
); |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool isAutoCheckUpdatesEnabled = true; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyPropertyChangedFor(nameof(SelectedUpdateChannelCard))] |
||||||
|
private UpdateChannel preferredUpdateChannel = UpdateChannel.Stable; |
||||||
|
|
||||||
|
public UpdateChannelCard? SelectedUpdateChannelCard |
||||||
|
{ |
||||||
|
get => AvailableUpdateChannelCards.First(c => c.UpdateChannel == PreferredUpdateChannel); |
||||||
|
set => PreferredUpdateChannel = value?.UpdateChannel ?? UpdateChannel.Stable; |
||||||
|
} |
||||||
|
|
||||||
|
public IReadOnlyList<UpdateChannelCard> AvailableUpdateChannelCards { get; } = |
||||||
|
new UpdateChannelCard[] |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
UpdateChannel = UpdateChannel.Development, |
||||||
|
Description = Resources.Label_UpdatesDevChannelDescription |
||||||
|
}, |
||||||
|
new() |
||||||
|
{ |
||||||
|
UpdateChannel = UpdateChannel.Preview, |
||||||
|
Description = Resources.Label_UpdatesPreviewChannelDescription |
||||||
|
}, |
||||||
|
new() { UpdateChannel = UpdateChannel.Stable } |
||||||
|
}; |
||||||
|
|
||||||
|
public UpdateSettingsViewModel( |
||||||
|
ISettingsManager settingsManager, |
||||||
|
IUpdateHelper updateHelper, |
||||||
|
IAccountsService accountsService, |
||||||
|
INavigationService<SettingsViewModel> settingsNavService |
||||||
|
) |
||||||
|
{ |
||||||
|
this.updateHelper = updateHelper; |
||||||
|
this.accountsService = accountsService; |
||||||
|
this.settingsNavService = settingsNavService; |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.PreferredUpdateChannel, |
||||||
|
settings => settings.PreferredUpdateChannel, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor( |
||||||
|
this, |
||||||
|
vm => vm.IsAutoCheckUpdatesEnabled, |
||||||
|
settings => settings.CheckForUpdates, |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
accountsService.LykosAccountStatusUpdate += (_, args) => |
||||||
|
{ |
||||||
|
var isBetaChannelsEnabled = args.User?.IsActiveSupporter == true; |
||||||
|
|
||||||
|
foreach ( |
||||||
|
var card in AvailableUpdateChannelCards.Where( |
||||||
|
c => c.UpdateChannel > UpdateChannel.Stable |
||||||
|
) |
||||||
|
) |
||||||
|
{ |
||||||
|
card.IsSelectable = isBetaChannelsEnabled; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// On update status changed |
||||||
|
updateHelper.UpdateStatusChanged += (_, args) => |
||||||
|
{ |
||||||
|
UpdateStatus = args; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override async Task OnLoadedAsync() |
||||||
|
{ |
||||||
|
if (UpdateStatus is null) |
||||||
|
{ |
||||||
|
await CheckForUpdates(); |
||||||
|
} |
||||||
|
OnPropertyChanged(nameof(SubtitleText)); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task CheckForUpdates() |
||||||
|
{ |
||||||
|
if (Design.IsDesignMode) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
await updateHelper.CheckForUpdate(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Verify a new channel selection is valid, else returns false. |
||||||
|
/// </summary> |
||||||
|
/// <param name="card"></param> |
||||||
|
/// <returns></returns> |
||||||
|
public bool VerifyChannelSelection(UpdateChannelCard card) |
||||||
|
{ |
||||||
|
if (card.UpdateChannel == UpdateChannel.Stable) |
||||||
|
{ |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
if (accountsService.LykosStatus?.User?.IsActiveSupporter == true) |
||||||
|
{ |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
public void ShowLoginRequiredDialog() |
||||||
|
{ |
||||||
|
Dispatcher.UIThread |
||||||
|
.InvokeAsync(async () => |
||||||
|
{ |
||||||
|
var dialog = DialogHelper.CreateTaskDialog( |
||||||
|
"Become a Supporter", |
||||||
|
"" |
||||||
|
+ "Support the Stability Matrix Team and get access to early development builds and be the first to test new features. " |
||||||
|
); |
||||||
|
|
||||||
|
dialog.Buttons = new[] |
||||||
|
{ |
||||||
|
new(Resources.Label_Accounts, TaskDialogStandardResult.OK), |
||||||
|
TaskDialogButton.CloseButton |
||||||
|
}; |
||||||
|
|
||||||
|
dialog.Commands = new[] |
||||||
|
{ |
||||||
|
new TaskDialogCommand |
||||||
|
{ |
||||||
|
Text = "Patreon", |
||||||
|
Description = "https://patreon.com/StabilityMatrix", |
||||||
|
Command = new RelayCommand(() => |
||||||
|
{ |
||||||
|
ProcessRunner.OpenUrl("https://patreon.com/StabilityMatrix"); |
||||||
|
}) |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
if (await dialog.ShowAsync(true) is TaskDialogStandardResult.OK) |
||||||
|
{ |
||||||
|
settingsNavService.NavigateTo<AccountSettingsViewModel>( |
||||||
|
new SuppressNavigationTransitionInfo() |
||||||
|
); |
||||||
|
} |
||||||
|
}) |
||||||
|
.SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnUpdateStatusChanged(UpdateStatusChangedEventArgs? value) |
||||||
|
{ |
||||||
|
// Update the update channel cards |
||||||
|
|
||||||
|
// Use maximum version from platforms equal or lower than current |
||||||
|
foreach (var card in AvailableUpdateChannelCards) |
||||||
|
{ |
||||||
|
card.LatestVersion = value?.UpdateChannels |
||||||
|
.Where(kv => kv.Key <= card.UpdateChannel) |
||||||
|
.Select(kv => kv.Value) |
||||||
|
.MaxBy(info => info.Version, SemVersion.PrecedenceComparer) |
||||||
|
?.Version; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnPreferredUpdateChannelChanged(UpdateChannel value) |
||||||
|
{ |
||||||
|
CheckForUpdatesCommand.ExecuteAsync(null).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override string Title => "Updates"; |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public override IconSource IconSource => |
||||||
|
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
<controls:UserControlBase |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.LykosLoginDialog" |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||||
|
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia" |
||||||
|
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" |
||||||
|
xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia" |
||||||
|
Focusable="True" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.LykosLoginViewModel}" |
||||||
|
d:DesignHeight="350" |
||||||
|
d:DesignWidth="400" |
||||||
|
x:DataType="vmDialogs:LykosLoginViewModel" |
||||||
|
mc:Ignorable="d"> |
||||||
|
|
||||||
|
<sg:SpacedGrid MinWidth="400" MinHeight="300"> |
||||||
|
<!--<TextBlock Text="Log in or sign up" Theme="{DynamicResource SubtitleTextBlockStyle}" />--> |
||||||
|
<TabControl SelectedIndex="{Binding IsSignupMode}"> |
||||||
|
<TabItem |
||||||
|
FontSize="20" |
||||||
|
Header="Login"> |
||||||
|
|
||||||
|
<sg:SpacedGrid |
||||||
|
Margin="-4,32,-4,8" |
||||||
|
RowDefinitions="Auto,Auto,Auto,*" |
||||||
|
RowSpacing="8"> |
||||||
|
<!-- Username --> |
||||||
|
<TextBox |
||||||
|
Watermark="{x:Static lang:Resources.Label_Email}" |
||||||
|
Text="{Binding Email}" |
||||||
|
UseFloatingWatermark="True" |
||||||
|
Grid.Row="0" /> |
||||||
|
<!-- Password --> |
||||||
|
<MaskedTextBox |
||||||
|
PasswordChar="*" |
||||||
|
Text="{Binding Password}" |
||||||
|
Watermark="{x:Static lang:Resources.Label_Password}" |
||||||
|
Grid.Row="1" /> |
||||||
|
|
||||||
|
<ui:InfoBar |
||||||
|
Severity="Error" |
||||||
|
IsOpen="{Binding LoginError, Converter={x:Static ObjectConverters.IsNotNull}}" |
||||||
|
Title="{Binding LoginError.Message}" |
||||||
|
Message="{Binding LoginError.Details}" |
||||||
|
Grid.Row="2"/> |
||||||
|
|
||||||
|
<controls:HyperlinkIconButton |
||||||
|
VerticalAlignment="Bottom" |
||||||
|
Content="Forgot Password?" |
||||||
|
NavigateUri="{x:Static sm:Assets.LykosForgotPasswordUrl}" |
||||||
|
Grid.Row="3"/> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
</TabItem> |
||||||
|
<TabItem |
||||||
|
FontSize="20" |
||||||
|
Header="Sign up"> |
||||||
|
|
||||||
|
<sg:SpacedGrid |
||||||
|
Margin="-4,32,-4,8" |
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" |
||||||
|
RowSpacing="8"> |
||||||
|
<!-- Email --> |
||||||
|
<TextBox |
||||||
|
Watermark="{x:Static lang:Resources.Label_Email}" |
||||||
|
Text="{Binding Email}" |
||||||
|
UseFloatingWatermark="True" |
||||||
|
Grid.Row="0" /> |
||||||
|
<!-- Username --> |
||||||
|
<TextBox |
||||||
|
Watermark="{x:Static lang:Resources.Label_Username}" |
||||||
|
Text="{Binding Username}" |
||||||
|
UseFloatingWatermark="True" |
||||||
|
Grid.Row="1" /> |
||||||
|
<!-- Password --> |
||||||
|
<TextBox |
||||||
|
PasswordChar="*" |
||||||
|
Text="{Binding Password}" |
||||||
|
Watermark="{x:Static lang:Resources.Label_Password}" |
||||||
|
UseFloatingWatermark="True" |
||||||
|
Grid.Row="2" /> |
||||||
|
<!-- Confirm Password --> |
||||||
|
<TextBox |
||||||
|
PasswordChar="*" |
||||||
|
Text="{Binding ConfirmPassword}" |
||||||
|
Watermark="{x:Static lang:Resources.Label_ConfirmPassword}" |
||||||
|
UseFloatingWatermark="True" |
||||||
|
Grid.Row="3" /> |
||||||
|
|
||||||
|
<ui:InfoBar |
||||||
|
Severity="Error" |
||||||
|
IsOpen="{Binding SignupError, Converter={x:Static ObjectConverters.IsNotNull}}" |
||||||
|
Title="{Binding SignupError.Message}" |
||||||
|
Message="{Binding SignupError.Details}" |
||||||
|
Grid.Row="4"/> |
||||||
|
|
||||||
|
<mdxaml:MarkdownScrollViewer |
||||||
|
Grid.Row="5" |
||||||
|
Margin="4,0" |
||||||
|
VerticalAlignment="Bottom" |
||||||
|
MaxWidth="380" |
||||||
|
TextElement.Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||||
|
Markdown="{Binding SignupFooterMarkdown}"> |
||||||
|
<mdxaml:MarkdownScrollViewer.Styles> |
||||||
|
<Style Selector="ctxt|CHyperlink"> |
||||||
|
<Style.Setters> |
||||||
|
<Setter Property="IsUnderline" Value="False"/> |
||||||
|
</Style.Setters> |
||||||
|
</Style> |
||||||
|
<Style Selector="ctxt|CHyperlink:pointerover"> |
||||||
|
<Setter Property="IsUnderline" Value="True"/> |
||||||
|
<Setter Property="Foreground" Value="{StaticResource ThemeEldenRingOrangeColor}"/> |
||||||
|
</Style> |
||||||
|
</mdxaml:MarkdownScrollViewer.Styles> |
||||||
|
</mdxaml:MarkdownScrollViewer> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
</TabItem> |
||||||
|
</TabControl> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
|
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,13 @@ |
|||||||
|
using Avalonia.Controls; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
|
||||||
|
[Transient] |
||||||
|
public partial class LykosLoginDialog : UserControl |
||||||
|
{ |
||||||
|
public LykosLoginDialog() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,40 @@ |
|||||||
|
<controls:UserControlBase 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" |
||||||
|
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||||
|
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.OAuthConnectViewModel}" |
||||||
|
x:DataType="vmDialogs:OAuthConnectViewModel" |
||||||
|
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="400" |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.OAuthConnectDialog"> |
||||||
|
|
||||||
|
<sg:SpacedGrid RowDefinitions="Auto,Auto,Auto" Margin="8,4" RowSpacing="12"> |
||||||
|
<sg:SpacedGrid.Styles> |
||||||
|
<Style Selector="ui|HyperlinkButton > TextBlock"> |
||||||
|
<Setter Property="TextWrapping" Value="WrapWithOverflow"/> |
||||||
|
</Style> |
||||||
|
</sg:SpacedGrid.Styles> |
||||||
|
|
||||||
|
<ui:HyperlinkButton |
||||||
|
Padding="6,0" |
||||||
|
Content="{Binding Url}" |
||||||
|
NavigateUri="{Binding Url}"/> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="1" |
||||||
|
Margin="6,0" |
||||||
|
TextWrapping="WrapWithOverflow" |
||||||
|
Text="{Binding Description}"/> |
||||||
|
|
||||||
|
<ProgressBar |
||||||
|
IsIndeterminate="True" |
||||||
|
Grid.Row="2"/> |
||||||
|
</sg:SpacedGrid> |
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,13 @@ |
|||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views.Dialogs; |
||||||
|
|
||||||
|
[Transient] |
||||||
|
public partial class OAuthConnectDialog : UserControlBase |
||||||
|
{ |
||||||
|
public OAuthConnectDialog() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,179 @@ |
|||||||
|
<controls:UserControlBase |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.Settings.AccountSettingsPage" |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||||
|
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
|
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" |
||||||
|
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.AccountSettingsViewModel}" |
||||||
|
d:DesignHeight="450" |
||||||
|
d:DesignWidth="800" |
||||||
|
x:DataType="vmSettings:AccountSettingsViewModel" |
||||||
|
mc:Ignorable="d"> |
||||||
|
|
||||||
|
<controls:UserControlBase.Resources> |
||||||
|
<SolidColorBrush x:Key="Brush0" Color="#FF3C3C46" /> |
||||||
|
<SolidColorBrush x:Key="Brush1" Color="#FF3E4B77" /> |
||||||
|
<SolidColorBrush x:Key="Brush4" Color="#FF1375D5" /> |
||||||
|
<SolidColorBrush x:Key="Brush7" Color="#FF1B96E3" /> |
||||||
|
<SolidColorBrush x:Key="Brush13" Color="#FF5486BC" /> |
||||||
|
<DrawingImage x:Key="BrandsLykos"> |
||||||
|
<DrawingGroup> |
||||||
|
<GeometryDrawing Brush="Transparent" Geometry="F1M0,0L587,0L587,618L0,618z" /> |
||||||
|
<DrawingGroup> |
||||||
|
<DrawingGroup.Transform> |
||||||
|
<MatrixTransform Matrix="0.99998295,0,0,0.99998295,0,0.24023438" /> |
||||||
|
</DrawingGroup.Transform> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush0}" Geometry="F0 M359.12 181.81L290.92 263.57L202.31 422.8L296.41 349.51L341.33 282.47L359.12 181.81Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L296.41 349.51L287.33 463.38L402.14 326.47L405.15 227.55L342.3 165.1L354.51 195.18Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L373.25 246.84L402.14 326.47L475.55 241.31L506.38 172.37L432.6 170.07L354.51 195.18Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush0}" Geometry="F0 M506.38 172.37L402.14 326.47L431.69 421.7L493.66 289.28L506.38 172.37Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M354.51 195.18L431.14 183.67L506.38 172.37L581.91 114.38L577.7 83.96L459.71 128.22L354.51 195.18Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L577.7 83.96L587.01 36.06L555.98 24.72L444.52 90.52L354.51 195.18Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M354.51 195.18L451.44 116.55L551.26 35.59L570.72 16.37L543.04 5.86L469.78 26.31L346.91 82.41L330.57 157.07L354.51 195.18Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M258.82 209.64L283.43 257.76L354.51 195.18L354.73 143.18L354.97 88.91L300.3 145.74L258.82 209.64Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M284.89 201.03L283.82 132.98L245.45 133.94L209.32 211.39L209.53 265.05L202.31 422.8L277.33 274.62L284.89 201.03Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L318.27 198.22L284.89 201.03L202.31 422.8L268.99 323.08L354.51 195.18Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M209.53 265.05L225.86 205.45L245.45 133.94L157.16 269.41L209.53 265.05Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M245.45 133.94L301.48 136.45L354.97 88.91L275.66 59.42L139 44.21L245.45 133.94Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M139 44.21L287.77 75.01L354.97 88.91L348.14 53.88L306.32 49.68L139 44.21Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M393.94 11.53L306.32 49.68L354.97 88.91L412.38 57.61L441.91 26.85L393.94 11.53Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M441.91 26.85L354.97 88.91L543.04 5.86L571.25 0L441.91 26.85Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M571.25 0L543.04 5.86L551.26 35.59L587.01 36.06L571.25 0Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M151.49 224.62L33.55 321.2L103.23 316.08L151.49 224.62Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M145.63 400.32L200.67 282.86L0 447.89L145.63 400.32Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M262.66 413.33L132.89 597.28L236.55 530.32L262.66 413.33Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M374.93 617.53L350.75 441.52L396.59 364.19L412.64 459.88L374.93 617.53Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M277.5 507.65L312.09 557.16L324.24 472.27L277.5 507.65Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M185.82 455.66L119.65 488.21L183.32 389.39L185.82 455.66Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M441.13 526.17L473.16 366.86L489.5 454.43L441.13 526.17Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.97 88.91L354.97 88.91L245.45 133.94L284.89 201.03L316.79 149.99L354.97 88.91Z" /> |
||||||
|
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M353.83 211.72L346.35 393L402.14 326.47L373.25 246.84L356.02 199.33L354.51 195.18L353.83 211.72Z" /> |
||||||
|
</DrawingGroup> |
||||||
|
</DrawingGroup> |
||||||
|
</DrawingImage> |
||||||
|
|
||||||
|
<SolidColorBrush x:Key="BrushB0" Color="#FFFFFFFF" /> |
||||||
|
<DrawingImage x:Key="BrandsPatreonSymbolWhite"> |
||||||
|
<DrawingGroup> |
||||||
|
<GeometryDrawing Brush="{DynamicResource BrushB0}" Geometry="F1 M1033.05 324.45C1032.86 186.55 925.46 73.53 799.45 32.75C642.97 -17.89 436.59 -10.55 287.17 59.95C106.07 145.41 49.18 332.61 47.06 519.31C45.32 672.81 60.64 1077.1 288.68 1079.98C458.12 1082.13 483.35 863.8 561.75 758.65C617.53 683.84 689.35 662.71 777.76 640.83C929.71 603.22 1033.27 483.3 1033.05 324.45Z" /> |
||||||
|
</DrawingGroup> |
||||||
|
</DrawingImage> |
||||||
|
</controls:UserControlBase.Resources> |
||||||
|
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"> |
||||||
|
<StackPanel Margin="16,16" Spacing="2"> |
||||||
|
|
||||||
|
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,*"> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="16"> |
||||||
|
<fluentIcons:SymbolIcon |
||||||
|
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNullOrEmpty}}" |
||||||
|
Width="64" |
||||||
|
Height="64" |
||||||
|
IsFilled="True" |
||||||
|
FontSize="64" |
||||||
|
Symbol="Person" /> |
||||||
|
<ui:HyperlinkButton |
||||||
|
ToolTip.Tip="Edit on Gravatar" |
||||||
|
NavigateUri="http://gravatar.com/emails/" |
||||||
|
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||||
|
Padding="2" |
||||||
|
CornerRadius="8" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Classes="transparent-full"> |
||||||
|
<controls:BetterAdvancedImage |
||||||
|
Source="{Binding LykosProfileImageUrl}" |
||||||
|
Width="64" |
||||||
|
Height="64" |
||||||
|
CornerRadius="8" /> |
||||||
|
</ui:HyperlinkButton> |
||||||
|
|
||||||
|
|
||||||
|
<StackPanel> |
||||||
|
<TextBlock |
||||||
|
Margin="-1,0,0,0" |
||||||
|
Text="{Binding LykosStatus.User.Account.Name, FallbackValue=''}" |
||||||
|
Theme="{DynamicResource SubtitleTextBlockStyle}" /> |
||||||
|
<TextBlock Text="{Binding LykosStatus.User.Id, FallbackValue=''}" /> |
||||||
|
</StackPanel> |
||||||
|
</StackPanel> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
ConnectCommand="{Binding ConnectLykosCommand}" |
||||||
|
DisconnectCommand="{Binding DisconnectLykosCommand}" |
||||||
|
Header="Lykos" |
||||||
|
HeaderTargetUri="{x:Static sm:Assets.LykosUrl}" |
||||||
|
IsConnected="{Binding LykosStatus.IsConnected}" |
||||||
|
OffDescription="Manage connected features in Stability Matrix"> |
||||||
|
<controls:SettingsAccountLinkExpander.IconSource> |
||||||
|
<ui:ImageIconSource Source="{StaticResource BrandsLykos}" /> |
||||||
|
</controls:SettingsAccountLinkExpander.IconSource> |
||||||
|
<controls:SettingsAccountLinkExpander.IsLoading> |
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.Or}"> |
||||||
|
<Binding Path="ConnectLykosCommand.IsRunning" /> |
||||||
|
<Binding Path="!IsInitialUpdateFinished" /> |
||||||
|
</MultiBinding> |
||||||
|
</controls:SettingsAccountLinkExpander.IsLoading> |
||||||
|
</controls:SettingsAccountLinkExpander> |
||||||
|
|
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
ConnectCommand="{Binding ConnectPatreonCommand}" |
||||||
|
DisconnectCommand="{Binding DisconnectPatreonCommand}" |
||||||
|
Header="Patreon" |
||||||
|
IsEnabled="{Binding LykosStatus.IsConnected}" |
||||||
|
HeaderTargetUri="{x:Static sm:Assets.PatreonUrl}" |
||||||
|
IsConnected="{Binding IsPatreonConnected}" |
||||||
|
OffDescription="Access Preview and Dev release channels for auto-updates"> |
||||||
|
<controls:SettingsAccountLinkExpander.IconSource> |
||||||
|
<ui:ImageIconSource Source="{StaticResource BrandsPatreonSymbolWhite}" /> |
||||||
|
</controls:SettingsAccountLinkExpander.IconSource> |
||||||
|
<controls:SettingsAccountLinkExpander.IsLoading> |
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.Or}"> |
||||||
|
<Binding Path="ConnectPatreonCommand.IsRunning" /> |
||||||
|
<Binding Path="!IsInitialUpdateFinished" /> |
||||||
|
</MultiBinding> |
||||||
|
</controls:SettingsAccountLinkExpander.IsLoading> |
||||||
|
</controls:SettingsAccountLinkExpander> |
||||||
|
|
||||||
|
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4" Margin="0,6,0,0"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,4" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_Integrations}" /> |
||||||
|
|
||||||
|
<controls:SettingsAccountLinkExpander |
||||||
|
Grid.Row="2" |
||||||
|
ConnectCommand="{Binding ConnectCivitCommand}" |
||||||
|
DisconnectCommand="{Binding DisconnectCivitCommand}" |
||||||
|
Header="CivitAI" |
||||||
|
HeaderTargetUri="{x:Static sm:Assets.CivitAIUrl}" |
||||||
|
IsConnected="{Binding CivitStatus.IsConnected}" |
||||||
|
OnDescriptionExtra="{Binding CivitStatus.UsernameWithParentheses}" |
||||||
|
OffDescription="Connect to Download Models that require login"> |
||||||
|
<controls:SettingsAccountLinkExpander.IconSource> |
||||||
|
<ui:BitmapIconSource UriSource="avares://StabilityMatrix.Avalonia/Assets/brands-civitai.png" /> |
||||||
|
</controls:SettingsAccountLinkExpander.IconSource> |
||||||
|
<controls:SettingsAccountLinkExpander.IsLoading> |
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.Or}"> |
||||||
|
<Binding Path="ConnectCivitCommand.IsRunning" /> |
||||||
|
<Binding Path="!IsInitialUpdateFinished" /> |
||||||
|
</MultiBinding> |
||||||
|
</controls:SettingsAccountLinkExpander.IsLoading> |
||||||
|
</controls:SettingsAccountLinkExpander> |
||||||
|
|
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
</StackPanel> |
||||||
|
</ScrollViewer> |
||||||
|
|
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,13 @@ |
|||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
|
||||||
|
[Singleton] |
||||||
|
public partial class AccountSettingsPage : UserControlBase |
||||||
|
{ |
||||||
|
public AccountSettingsPage() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
} |
@ -1,16 +1,69 @@ |
|||||||
<controls:UserControlBase xmlns="https://github.com/avaloniaui" |
<controls:UserControlBase |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage" |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls" |
|
||||||
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
|
||||||
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||||
|
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" |
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" |
||||||
d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}" |
d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}" |
||||||
|
d:DesignHeight="450" |
||||||
|
d:DesignWidth="800" |
||||||
x:DataType="vmSettings:InferenceSettingsViewModel" |
x:DataType="vmSettings:InferenceSettingsViewModel" |
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
mc:Ignorable="d"> |
||||||
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage"> |
<ScrollViewer VerticalScrollBarVisibility="Auto"> |
||||||
Welcome to Avalonia! |
<StackPanel Margin="8,16" Spacing="8"> |
||||||
|
<!-- Theme --> |
||||||
|
<Grid RowDefinitions="Auto,*,*,*"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="Stuff" /> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="{x:Static lang:Resources.Label_Theme}" |
||||||
|
IconSource="WeatherMoon"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<ComboBox MinWidth="100" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="{x:Static lang:Resources.Label_Language}" |
||||||
|
IconSource="Character"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<ComboBox MinWidth="100" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="3" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="Inference" |
||||||
|
IconSource="Code" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Checkpoints Manager Options --> |
||||||
|
<Grid RowDefinitions="auto,*,Auto"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="Other stuff" /> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0" |
||||||
|
IconSource="Folder"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<CheckBox Margin="8" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
</Grid> |
||||||
|
</StackPanel> |
||||||
|
</ScrollViewer> |
||||||
</controls:UserControlBase> |
</controls:UserControlBase> |
||||||
|
@ -1,19 +1,13 @@ |
|||||||
using Avalonia.Controls; |
using StabilityMatrix.Avalonia.Controls; |
||||||
using Avalonia.Markup.Xaml; |
|
||||||
using StabilityMatrix.Core.Attributes; |
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
namespace StabilityMatrix.Avalonia.Views.Settings; |
namespace StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
|
||||||
[Singleton] |
[Singleton] |
||||||
public partial class InferenceSettingsPage : UserControl |
public partial class InferenceSettingsPage : UserControlBase |
||||||
{ |
{ |
||||||
public InferenceSettingsPage() |
public InferenceSettingsPage() |
||||||
{ |
{ |
||||||
InitializeComponent(); |
InitializeComponent(); |
||||||
} |
} |
||||||
|
|
||||||
private void InitializeComponent() |
|
||||||
{ |
|
||||||
AvaloniaXamlLoader.Load(this); |
|
||||||
} |
|
||||||
} |
} |
||||||
|
@ -0,0 +1,572 @@ |
|||||||
|
<controls:UserControlBase |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.Settings.MainSettingsPage" |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:avalonia="https://github.com/projektanker/icons.avalonia" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" |
||||||
|
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.Models.Inference" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||||
|
xmlns:update="clr-namespace:StabilityMatrix.Core.Models.Update;assembly=StabilityMatrix.Core" |
||||||
|
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
|
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}" |
||||||
|
d:DesignHeight="700" |
||||||
|
d:DesignWidth="800" |
||||||
|
x:CompileBindings="True" |
||||||
|
x:DataType="vmSettings:MainSettingsViewModel" |
||||||
|
Focusable="True" |
||||||
|
mc:Ignorable="d"> |
||||||
|
|
||||||
|
<controls:UserControlBase.Resources> |
||||||
|
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" /> |
||||||
|
<converters:EnumStringConverter x:Key="EnumStringConverter" /> |
||||||
|
<converters:EnumToBooleanConverter x:Key="EnumBoolConverter" /> |
||||||
|
</controls:UserControlBase.Resources> |
||||||
|
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"> |
||||||
|
<StackPanel Margin="8,16" Spacing="8"> |
||||||
|
<!-- Theme --> |
||||||
|
<Grid RowDefinitions="Auto,*,*,*"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_Appearance}" /> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="{x:Static lang:Resources.Label_Theme}" |
||||||
|
IconSource="WeatherMoon"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<ComboBox |
||||||
|
MinWidth="100" |
||||||
|
ItemsSource="{Binding AvailableThemes}" |
||||||
|
SelectedItem="{Binding SelectedTheme}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="{x:Static lang:Resources.Label_Language}" |
||||||
|
IconSource="Character"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<ComboBox |
||||||
|
MinWidth="100" |
||||||
|
DisplayMemberBinding="{Binding Converter={StaticResource CultureInfoDisplayConverter}}" |
||||||
|
ItemsSource="{Binding AvailableLanguages}" |
||||||
|
SelectedItem="{Binding SelectedLanguage}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="3" |
||||||
|
Margin="8,0,8,4" |
||||||
|
ActionIconSource="ChevronRight" |
||||||
|
Command="{Binding NavigateToSubPageCommand}" |
||||||
|
CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}" |
||||||
|
Header="Inference (Test)" |
||||||
|
IconSource="Code" |
||||||
|
IsClickEnabled="True" |
||||||
|
IsVisible="{Binding SharedState.IsDebugMode}" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Checkpoints Manager Options --> |
||||||
|
<Grid RowDefinitions="auto,*,Auto"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_CheckpointManager}" /> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0" |
||||||
|
Description="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown_Details}" |
||||||
|
Header="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown}" |
||||||
|
IconSource="Folder"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<CheckBox Margin="8" IsChecked="{Binding RemoveSymlinksOnShutdown}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,4" |
||||||
|
Description="{x:Static lang:Resources.Label_ResetCheckpointsCache_Details}" |
||||||
|
Header="{x:Static lang:Resources.Label_ResetCheckpointsCache}" |
||||||
|
IconSource="Refresh"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<Button Command="{Binding ResetCheckpointCache}" Content="{x:Static lang:Resources.Label_ResetCheckpointsCache}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Inference UI --> |
||||||
|
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*,*"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="Inference" /> |
||||||
|
<!-- Auto Completion --> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="Prompt Auto Completion"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<controls:FASymbolIconSource Symbol="fa-solid fa-wand-magic-sparkles" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
|
||||||
|
<!-- Enable toggle --> |
||||||
|
<ui:SettingsExpanderItem Content="Enable"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<ToggleSwitch IsChecked="{Binding IsPromptCompletionEnabled}" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<!-- Tag csv selection --> |
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Content="Tag Source" |
||||||
|
Description="Tags to use for completion in .csv format (Compatible with a1111-sd-webui-tagcomplete)" |
||||||
|
IconSource="Tag" |
||||||
|
IsEnabled="{Binding IsPromptCompletionEnabled}"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<ui:FAComboBox ItemsSource="{Binding AvailableTagCompletionCsvs}" SelectedItem="{Binding SelectedTagCompletionCsv}" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<!-- Tag csv import --> |
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Content="Import Tag Source .csv" |
||||||
|
IconSource="Add" |
||||||
|
IsEnabled="{Binding IsPromptCompletionEnabled}"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button Command="{Binding ImportTagCsvCommand}" Content="Import" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<!-- Remove underscores --> |
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Content="Replace underscores with spaces when inserting completions" |
||||||
|
IconSource="Underline" |
||||||
|
IsEnabled="{Binding IsPromptCompletionEnabled}"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<CheckBox Margin="8" IsChecked="{Binding IsCompletionRemoveUnderscoresEnabled}" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<!-- Image Viewer --> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="Image Viewer" |
||||||
|
IconSource="Image"> |
||||||
|
|
||||||
|
<!-- Pixel grid --> |
||||||
|
<ui:SettingsExpanderItem Content="Show pixel grid at high zoom levels" IconSource="ViewAll"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<CheckBox Margin="8" IsChecked="{Binding IsImageViewerPixelGridEnabled}" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<!-- Output Image Files --> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="3" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="Output Image Files"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<fluentIcons:SymbolIconSource Symbol="TabDesktopImage" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
<!-- File name pattern --> |
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Content="File name pattern" |
||||||
|
Description="{Binding OutputImageFileNameFormatSample}" |
||||||
|
IconSource="Rename"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<TextBox |
||||||
|
Name="OutputImageFileNameFormatTextBox" |
||||||
|
MinWidth="150" |
||||||
|
FontFamily="Cascadia Code,Consolas,Menlo,Monospace" |
||||||
|
FontSize="13" |
||||||
|
Text="{Binding OutputImageFileNameFormat}" |
||||||
|
Watermark="{x:Static inference:FileNameFormat.DefaultTemplate}" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<ui:TeachingTip |
||||||
|
Title="Format Variables" |
||||||
|
Grid.Row="3" |
||||||
|
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}" |
||||||
|
PreferredPlacement="Top" |
||||||
|
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}"> |
||||||
|
<DataGrid AutoGenerateColumns="True" ItemsSource="{Binding OutputImageFileNameFormatVars}" /> |
||||||
|
<!--<mdxaml:MarkdownScrollViewer |
||||||
|
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>--> |
||||||
|
</ui:TeachingTip> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Environment Options --> |
||||||
|
<Grid RowDefinitions="Auto, Auto, Auto"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_PackageEnvironment}" /> |
||||||
|
|
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0" |
||||||
|
Header="{x:Static lang:Resources.Label_EnvironmentVariables}" |
||||||
|
IconSource="OtherUser"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<Button Command="{Binding OpenEnvVarsDialogCommand}" Content="{x:Static lang:Resources.Action_Edit}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,4" |
||||||
|
Header="{x:Static lang:Resources.Label_EmbeddedPython}"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<controls:FASymbolIconSource Symbol="fa-brands fa-python" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="16"> |
||||||
|
<controls:ProgressRing |
||||||
|
BorderThickness="3" |
||||||
|
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}" |
||||||
|
IsIndeterminate="True" |
||||||
|
IsVisible="{Binding CheckPythonVersionCommand.IsRunning}" /> |
||||||
|
<Button Command="{Binding CheckPythonVersionCommand}" Content="{x:Static lang:Resources.Action_CheckVersion}" /> |
||||||
|
</StackPanel> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Integrations --> |
||||||
|
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,4" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_Integrations}" /> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0" |
||||||
|
ActionIconSource="ChevronRight" |
||||||
|
Command="{Binding NavigateToSubPageCommand}" |
||||||
|
CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}" |
||||||
|
Header="{x:Static lang:Resources.Label_Accounts}" |
||||||
|
IsClickEnabled="True"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<fluentIcons:SymbolIconSource |
||||||
|
FontSize="10" |
||||||
|
IsFilled="True" |
||||||
|
Symbol="Person" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
</ui:SettingsExpander> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,0,8,4" |
||||||
|
Header="{x:Static lang:Resources.Label_DiscordRichPresence}"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<controls:FASymbolIconSource Symbol="fa-brands fa-discord" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<ToggleSwitch IsChecked="{Binding IsDiscordRichPresenceEnabled}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
<!-- System Options --> |
||||||
|
<sg:SpacedGrid RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="4"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,4" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_System}" /> |
||||||
|
|
||||||
|
<!-- Updates page --> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0" |
||||||
|
ActionIconSource="ChevronRight" |
||||||
|
Command="{Binding NavigateToSubPageCommand}" |
||||||
|
CommandParameter="{x:Type vmSettings:UpdateSettingsViewModel}" |
||||||
|
Header="{x:Static lang:Resources.Label_Updates}" |
||||||
|
IsClickEnabled="True"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<fluentIcons:SymbolIconSource |
||||||
|
FontSize="10" |
||||||
|
IsFilled="True" |
||||||
|
Symbol="ArrowSync" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="2" |
||||||
|
Margin="8,0" |
||||||
|
Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}" |
||||||
|
Header="{x:Static lang:Resources.Label_AddToStartMenu}" |
||||||
|
IconSource="StarAdd" |
||||||
|
ToolTip.Tip="{OnPlatform Default={x:Static lang:Resources.Label_OnlyAvailableOnWindows}, |
||||||
|
Windows={x:Null}}"> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"> |
||||||
|
<controls:ProgressRing |
||||||
|
BorderThickness="3" |
||||||
|
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}" |
||||||
|
IsIndeterminate="True"> |
||||||
|
<controls:ProgressRing.IsVisible> |
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.Or}"> |
||||||
|
<Binding Path="AddToStartMenuCommand.IsRunning" /> |
||||||
|
<Binding Path="AddToGlobalStartMenuCommand.IsRunning" /> |
||||||
|
</MultiBinding> |
||||||
|
</controls:ProgressRing.IsVisible> |
||||||
|
</controls:ProgressRing> |
||||||
|
|
||||||
|
<SplitButton |
||||||
|
Command="{Binding AddToStartMenuCommand}" |
||||||
|
Content="{x:Static lang:Resources.Action_AddForCurrentUser}" |
||||||
|
IsEnabled="{OnPlatform Default=False, |
||||||
|
Windows=True}"> |
||||||
|
<SplitButton.Flyout> |
||||||
|
<ui:FAMenuFlyout Placement="Bottom"> |
||||||
|
<ui:MenuFlyoutItem |
||||||
|
Command="{Binding AddToGlobalStartMenuCommand}" |
||||||
|
IconSource="Admin" |
||||||
|
Text="{x:Static lang:Resources.Action_AddForAllUsers}" /> |
||||||
|
</ui:FAMenuFlyout> |
||||||
|
</SplitButton.Flyout> |
||||||
|
</SplitButton> |
||||||
|
</StackPanel> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="3" |
||||||
|
Margin="8,0" |
||||||
|
Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}" |
||||||
|
IconSource="MoveToFolder"> |
||||||
|
<ui:SettingsExpander.Header> |
||||||
|
<StackPanel Orientation="Vertical"> |
||||||
|
<TextBlock Text="{x:Static lang:Resources.Label_SelectNewDataDirectory}" /> |
||||||
|
<TextBlock FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}"> |
||||||
|
<Run Text="{x:Static lang:Resources.Label_CurrentDirectory}" /> |
||||||
|
<Run Text="{Binding DataDirectory}" /> |
||||||
|
</TextBlock> |
||||||
|
</StackPanel> |
||||||
|
</ui:SettingsExpander.Header> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<Button Command="{Binding PickNewDataDirectory}"> |
||||||
|
<Grid ColumnDefinitions="Auto, Auto"> |
||||||
|
<avalonia:Icon |
||||||
|
Grid.Row="0" |
||||||
|
Margin="0,0,8,0" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Value="fa-solid fa-folder-open" /> |
||||||
|
<TextBlock |
||||||
|
Grid.Column="1" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{x:Static lang:Resources.Action_SelectDirectory}" /> |
||||||
|
</Grid> |
||||||
|
</Button> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
<!-- Debug Options --> |
||||||
|
<Grid IsVisible="{Binding SharedState.IsDebugMode}" RowDefinitions="auto,*"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,0,0,8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="Debug Options" /> |
||||||
|
<ui:SettingsExpander |
||||||
|
Grid.Row="1" |
||||||
|
Margin="8,0,8,0" |
||||||
|
Command="{Binding LoadDebugInfo}" |
||||||
|
Header="Debug Options" |
||||||
|
IconSource="Code"> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Description="Paths" |
||||||
|
IconSource="Folder"> |
||||||
|
<SelectableTextBlock |
||||||
|
Foreground="{DynamicResource TextControlPlaceholderForeground}" |
||||||
|
Text="{Binding DebugPaths}" |
||||||
|
TextWrapping="WrapWithOverflow" /> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Description="Compat Info" |
||||||
|
IconSource="StarFilled"> |
||||||
|
<SelectableTextBlock |
||||||
|
Foreground="{DynamicResource TextControlPlaceholderForeground}" |
||||||
|
Text="{Binding DebugCompatInfo}" |
||||||
|
TextWrapping="WrapWithOverflow" /> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Description="GPU Info" |
||||||
|
IconSource="FullScreenMaximize"> |
||||||
|
<SelectableTextBlock |
||||||
|
Foreground="{DynamicResource TextControlPlaceholderForeground}" |
||||||
|
Text="{Binding DebugGpuInfo}" |
||||||
|
TextWrapping="WrapWithOverflow" /> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Content="Animation Scale" |
||||||
|
Description="Lower values = faster animations. 0x means animations are instant." |
||||||
|
IconSource="Clock"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<ComboBox ItemsSource="{Binding AnimationScaleOptions}" SelectedItem="{Binding SelectedAnimationScale}"> |
||||||
|
<ComboBox.ItemTemplate> |
||||||
|
<DataTemplate> |
||||||
|
<TextBlock> |
||||||
|
<Run Text="{Binding}" /><Run Text="x" /> |
||||||
|
</TextBlock> |
||||||
|
</DataTemplate> |
||||||
|
</ComboBox.ItemTemplate> |
||||||
|
</ComboBox> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Content="Notification" |
||||||
|
IconSource="CommentAdd"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button Command="{Binding DebugNotificationCommand}" Content="New Notification" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Content="Content Dialog" |
||||||
|
IconSource="NewWindow"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button Command="{Binding DebugContentDialogCommand}" Content="Show Dialog" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0" |
||||||
|
Content="Exceptions" |
||||||
|
IconSource="Flag"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button Command="{Binding DebugThrowExceptionCommand}" Content="Unhandled Exception" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0,4,4" |
||||||
|
Content="Download Manager tests" |
||||||
|
IconSource="Flag"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button |
||||||
|
Margin="0,8" |
||||||
|
Command="{Binding DebugTrackedDownloadCommand}" |
||||||
|
Content="Add Tracked Download" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0,4,4" |
||||||
|
Content="Refresh Models Index" |
||||||
|
IconSource="SyncFolder"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button |
||||||
|
Margin="0,8" |
||||||
|
Command="{Binding DebugRefreshModelsIndexCommand}" |
||||||
|
Content="Refresh Index" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0,4,4" |
||||||
|
Content="Make image grid" |
||||||
|
IconSource="Image"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button |
||||||
|
Margin="0,8" |
||||||
|
Command="{Binding DebugMakeImageGridCommand}" |
||||||
|
Content="Select images" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem |
||||||
|
Margin="4,0,4,4" |
||||||
|
Content="Image metadata parser" |
||||||
|
IconSource="Flag"> |
||||||
|
<ui:SettingsExpanderItem.Footer> |
||||||
|
<Button |
||||||
|
Margin="0,8" |
||||||
|
Command="{Binding DebugImageMetadataCommand}" |
||||||
|
Content="Choose image" /> |
||||||
|
</ui:SettingsExpanderItem.Footer> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
</ui:SettingsExpander> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- TODO: Directories card --> |
||||||
|
|
||||||
|
<Grid RowDefinitions="auto,*"> |
||||||
|
<StackPanel |
||||||
|
Grid.Row="1" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Orientation="Vertical"> |
||||||
|
<TextBlock |
||||||
|
Margin="0,8" |
||||||
|
FontSize="15" |
||||||
|
FontWeight="Bold" |
||||||
|
Text="{x:Static lang:Resources.Label_About}" /> |
||||||
|
<Image |
||||||
|
Width="112" |
||||||
|
Height="112" |
||||||
|
Margin="8" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Source="/Assets/Icon.png" /> |
||||||
|
<TextBlock |
||||||
|
Margin="8" |
||||||
|
FontWeight="Medium" |
||||||
|
Text="{x:Static lang:Resources.Label_StabilityMatrix}" /> |
||||||
|
<Panel> |
||||||
|
<Button |
||||||
|
Name="VersionButton" |
||||||
|
Margin="8,0,8,8" |
||||||
|
Padding="2,0,2,0" |
||||||
|
BorderThickness="0" |
||||||
|
Classes="transparent" |
||||||
|
Command="{Binding OnVersionClick}" |
||||||
|
Content="{Binding AppVersion}" /> |
||||||
|
<ui:TeachingTip |
||||||
|
Title="{Binding VersionFlyoutText}" |
||||||
|
IsOpen="{Binding IsVersionTapTeachingTipOpen}" |
||||||
|
PreferredPlacement="RightTop" |
||||||
|
Target="{Binding #VersionButton}" /> |
||||||
|
</Panel> |
||||||
|
|
||||||
|
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal"> |
||||||
|
<Button |
||||||
|
Margin="8" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Command="{Binding ShowLicensesDialogCommand}" |
||||||
|
Content="{x:Static lang:Resources.Label_LicenseAndOpenSourceNotices}" /> |
||||||
|
</StackPanel> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Extra space at the bottom --> |
||||||
|
<Panel Margin="0,0,0,16" /> |
||||||
|
</StackPanel> |
||||||
|
</ScrollViewer> |
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,13 @@ |
|||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
|
||||||
|
[Singleton] |
||||||
|
public partial class MainSettingsPage : UserControlBase |
||||||
|
{ |
||||||
|
public MainSettingsPage() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,154 @@ |
|||||||
|
<controls:UserControlBase |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.Settings.UpdateSettingsPage" |
||||||
|
xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" |
||||||
|
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" |
||||||
|
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||||
|
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia" |
||||||
|
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||||
|
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
|
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" |
||||||
|
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||||
|
d:DataContext="{x:Static mocks:DesignData.UpdateSettingsViewModel}" |
||||||
|
d:DesignHeight="550" |
||||||
|
d:DesignWidth="800" |
||||||
|
x:DataType="vmSettings:UpdateSettingsViewModel" |
||||||
|
mc:Ignorable="d"> |
||||||
|
|
||||||
|
<controls:UserControlBase.Resources> |
||||||
|
<converters:BooleanChoiceMultiConverter x:Key="BoolChoiceMultiConverter" /> |
||||||
|
</controls:UserControlBase.Resources> |
||||||
|
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"> |
||||||
|
<StackPanel Margin="16,16,24,16" Spacing="2"> |
||||||
|
|
||||||
|
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,Auto"> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="16"> |
||||||
|
<fluentIcons:SymbolIcon |
||||||
|
Width="64" |
||||||
|
Height="64" |
||||||
|
FontSize="64" |
||||||
|
IsFilled="True" |
||||||
|
Symbol="ArrowSync" /> |
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center"> |
||||||
|
<TextBlock |
||||||
|
Margin="-1,0,0,0" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Text="{Binding HeaderText}" |
||||||
|
Theme="{DynamicResource SubtitleTextBlockStyle}" /> |
||||||
|
<TextBlock |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||||
|
Text="{Binding SubtitleText}" /> |
||||||
|
</StackPanel> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<Button |
||||||
|
Grid.Column="1" |
||||||
|
Classes="accent" |
||||||
|
Content="{x:Static lang:Resources.Action_CheckForUpdates}" |
||||||
|
Command="{Binding CheckForUpdatesCommand}" |
||||||
|
HorizontalAlignment="Right"> |
||||||
|
</Button> |
||||||
|
</sg:SpacedGrid> |
||||||
|
|
||||||
|
<!-- Auto updates toggle --> |
||||||
|
<ui:SettingsExpander |
||||||
|
Description="Periodically checks for updates" |
||||||
|
Header="Notify when updates are ready to install"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<fluentIcons:SymbolIconSource Symbol="Megaphone" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
<ui:SettingsExpander.Footer> |
||||||
|
<ToggleSwitch IsChecked="{Binding IsAutoCheckUpdatesEnabled}" /> |
||||||
|
</ui:SettingsExpander.Footer> |
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
<!-- Channel radio buttons --> |
||||||
|
<ui:SettingsExpander Header="Preferred Update Channel" IsExpanded="True"> |
||||||
|
<ui:SettingsExpander.IconSource> |
||||||
|
<fluentIcons:SymbolIconSource Symbol="Branch" /> |
||||||
|
</ui:SettingsExpander.IconSource> |
||||||
|
|
||||||
|
<ui:SettingsExpanderItem> |
||||||
|
<ListBox |
||||||
|
SelectionChanged="ChannelListBox_OnSelectionChanged" |
||||||
|
IsEnabled="{Binding IsAutoCheckUpdatesEnabled}" |
||||||
|
ItemContainerTheme="{DynamicResource ListBoxItemBorderlessTheme}" |
||||||
|
ItemsSource="{Binding AvailableUpdateChannelCards}" |
||||||
|
SelectedItem="{Binding SelectedUpdateChannelCard}"> |
||||||
|
<ListBox.ItemTemplate> |
||||||
|
<DataTemplate DataType="{x:Type models:UpdateChannelCard}"> |
||||||
|
<RadioButton |
||||||
|
GroupName="UpdateChannel" |
||||||
|
IsChecked="{Binding $parent[ListBoxItem].IsSelected}" > |
||||||
|
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Left"> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5"> |
||||||
|
<TextBlock |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{Binding DisplayName}"> |
||||||
|
<TextBlock.Foreground> |
||||||
|
<MultiBinding Converter="{StaticResource BoolChoiceMultiConverter}"> |
||||||
|
<Binding Path="IsSelectable" /> |
||||||
|
<DynamicResource ResourceKey="TextFillColorPrimaryBrush"/> |
||||||
|
<DynamicResource ResourceKey="TextFillColorTertiaryBrush"/> |
||||||
|
</MultiBinding> |
||||||
|
</TextBlock.Foreground> |
||||||
|
</TextBlock> |
||||||
|
<Ellipse |
||||||
|
HorizontalAlignment="Left" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||||
|
Width="4" |
||||||
|
Height="4" |
||||||
|
Fill="{DynamicResource TextFillColorTertiaryBrush}" /> |
||||||
|
<TextBlock |
||||||
|
VerticalAlignment="Center" |
||||||
|
FontSize="13" |
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||||
|
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||||
|
Text="{Binding LatestVersionString}" /> |
||||||
|
|
||||||
|
<!-- Update available icon --> |
||||||
|
<fluentIcons:SymbolIcon |
||||||
|
ToolTip.Tip="{x:Static lang:Resources.Label_UpdateAvailable}" |
||||||
|
IsVisible="{Binding IsLatestVersionUpdateable}" |
||||||
|
VerticalAlignment="Center" |
||||||
|
FontSize="16" |
||||||
|
Foreground="#3592c4" |
||||||
|
Symbol="ArrowCircleUpRight" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
FontSize="13" |
||||||
|
TextAlignment="Start" |
||||||
|
Margin="0,0,0,0" |
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||||
|
Text="{Binding Description}" |
||||||
|
Theme="{DynamicResource CaptionTextBlockStyle}" /> |
||||||
|
</StackPanel> |
||||||
|
</RadioButton> |
||||||
|
</DataTemplate> |
||||||
|
</ListBox.ItemTemplate> |
||||||
|
<ListBox.ItemsPanel> |
||||||
|
<ItemsPanelTemplate> |
||||||
|
<StackPanel Spacing="8" /> |
||||||
|
</ItemsPanelTemplate> |
||||||
|
</ListBox.ItemsPanel> |
||||||
|
</ListBox> |
||||||
|
</ui:SettingsExpanderItem> |
||||||
|
|
||||||
|
|
||||||
|
</ui:SettingsExpander> |
||||||
|
|
||||||
|
</StackPanel> |
||||||
|
</ScrollViewer> |
||||||
|
</controls:UserControlBase> |
@ -0,0 +1,41 @@ |
|||||||
|
using System.Linq; |
||||||
|
using Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Controls; |
||||||
|
using StabilityMatrix.Avalonia.Models; |
||||||
|
using StabilityMatrix.Avalonia.ViewModels.Settings; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Models.Update; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views.Settings; |
||||||
|
|
||||||
|
[Singleton] |
||||||
|
public partial class UpdateSettingsPage : UserControlBase |
||||||
|
{ |
||||||
|
public UpdateSettingsPage() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
|
||||||
|
private void ChannelListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) |
||||||
|
{ |
||||||
|
var listBox = (ListBox)sender!; |
||||||
|
|
||||||
|
if (e.AddedItems.Count == 0 || e.AddedItems[0] is not UpdateChannelCard item) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var vm = (UpdateSettingsViewModel)DataContext!; |
||||||
|
|
||||||
|
if (!vm.VerifyChannelSelection(item)) |
||||||
|
{ |
||||||
|
listBox.Selection.Clear(); |
||||||
|
|
||||||
|
listBox.Selection.SelectedItem = vm.AvailableUpdateChannelCards.First( |
||||||
|
c => c.UpdateChannel == UpdateChannel.Stable |
||||||
|
); |
||||||
|
|
||||||
|
vm.ShowLoginRequiredDialog(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,55 @@ |
|||||||
|
using Refit; |
||||||
|
using StabilityMatrix.Core.Models.Api.CivitTRPC; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Api; |
||||||
|
|
||||||
|
[Headers( |
||||||
|
"Content-Type: application/x-www-form-urlencoded", |
||||||
|
"Referer: https://civitai.com", |
||||||
|
"Origin: https://civitai.com" |
||||||
|
)] |
||||||
|
public interface ICivitTRPCApi |
||||||
|
{ |
||||||
|
[QueryUriFormat(UriFormat.UriEscaped)] |
||||||
|
[Get("/api/trpc/userProfile.get")] |
||||||
|
Task<CivitUserProfileResponse> GetUserProfile( |
||||||
|
[Query] CivitUserProfileRequest input, |
||||||
|
[Authorize] string bearerToken, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
[QueryUriFormat(UriFormat.UriEscaped)] |
||||||
|
[Get("/api/trpc/buzz.getUserAccount")] |
||||||
|
Task<CivitTrpcResponse<CivitUserAccountResponse>> GetUserAccount( |
||||||
|
[Query] string input, |
||||||
|
[Authorize] string bearerToken, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
Task<CivitTrpcResponse<CivitUserAccountResponse>> GetUserAccountDefault( |
||||||
|
string bearerToken, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
) |
||||||
|
{ |
||||||
|
return GetUserAccount( |
||||||
|
"{\"json\":null,\"meta\":{\"values\":[\"undefined\"]}}", |
||||||
|
bearerToken, |
||||||
|
cancellationToken |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
[QueryUriFormat(UriFormat.UriEscaped)] |
||||||
|
[Get("/api/trpc/user.getById")] |
||||||
|
Task<CivitTrpcResponse<CivitGetUserByIdResponse>> GetUserById( |
||||||
|
[Query] CivitGetUserByIdRequest input, |
||||||
|
[Authorize] string bearerToken, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
[Post("/api/trpc/user.toggleFavoriteModel")] |
||||||
|
Task<HttpResponseMessage> ToggleFavoriteModel( |
||||||
|
[Body] CivitUserToggleFavoriteModelRequest request, |
||||||
|
[Authorize] string bearerToken, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,73 @@ |
|||||||
|
using System.Net; |
||||||
|
using Refit; |
||||||
|
using StabilityMatrix.Core.Models.Api.Lykos; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Api; |
||||||
|
|
||||||
|
[Headers("User-Agent: StabilityMatrix")] |
||||||
|
public interface ILykosAuthApi |
||||||
|
{ |
||||||
|
[Headers("Authorization: Bearer")] |
||||||
|
[Get("/api/Users/{email}")] |
||||||
|
Task<GetUserResponse> GetUser(string email, CancellationToken cancellationToken = default); |
||||||
|
|
||||||
|
[Headers("Authorization: Bearer")] |
||||||
|
[Get("/api/Users/me")] |
||||||
|
Task<GetUserResponse> GetUserSelf(CancellationToken cancellationToken = default); |
||||||
|
|
||||||
|
[Post("/api/Accounts")] |
||||||
|
Task<LykosAccountTokens> PostAccount( |
||||||
|
[Body] PostAccountRequest request, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
[Post("/api/Login")] |
||||||
|
Task<LykosAccountTokens> PostLogin( |
||||||
|
[Body] PostLoginRequest request, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
[Post("/api/Login/Refresh")] |
||||||
|
Task<LykosAccountTokens> PostLoginRefresh( |
||||||
|
[Body] PostLoginRefreshRequest request, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
[Headers("Authorization: Bearer")] |
||||||
|
[Get("/api/oauth/patreon/redirect")] |
||||||
|
Task<HttpResponseMessage> GetPatreonOAuthRedirect( |
||||||
|
string redirectUrl, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
|
||||||
|
public async Task<string> GetPatreonOAuthUrl( |
||||||
|
string redirectUrl, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
) |
||||||
|
{ |
||||||
|
var result = await GetPatreonOAuthRedirect(redirectUrl, cancellationToken) |
||||||
|
.ConfigureAwait(false); |
||||||
|
|
||||||
|
if (result.StatusCode != HttpStatusCode.Redirect) |
||||||
|
{ |
||||||
|
result.EnsureSuccessStatusCode(); |
||||||
|
throw new InvalidOperationException( |
||||||
|
$"Expected a redirect 302 response, got {result.StatusCode}" |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return result.Headers.Location?.ToString() |
||||||
|
?? throw new InvalidOperationException("Expected a redirect URL, but got none"); |
||||||
|
} |
||||||
|
|
||||||
|
[Headers("Authorization: Bearer")] |
||||||
|
[Delete("/api/oauth/patreon")] |
||||||
|
Task DeletePatreonOAuth(CancellationToken cancellationToken = default); |
||||||
|
|
||||||
|
[Headers("Authorization: Bearer")] |
||||||
|
[Get("/api/files/download")] |
||||||
|
Task<GetFilesDownloadResponse> GetFilesDownload( |
||||||
|
string path, |
||||||
|
CancellationToken cancellationToken = default |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
namespace StabilityMatrix.Core.Api; |
||||||
|
|
||||||
|
public interface ITokenProvider |
||||||
|
{ |
||||||
|
Task<string> GetAccessTokenAsync(); |
||||||
|
Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync(); |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Models.Api.Lykos; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Api; |
||||||
|
|
||||||
|
[Singleton] |
||||||
|
public class LykosAuthTokenProvider : ITokenProvider |
||||||
|
{ |
||||||
|
private readonly ISecretsManager secretsManager; |
||||||
|
private readonly Lazy<ILykosAuthApi> lazyLykosAuthApi; |
||||||
|
|
||||||
|
public LykosAuthTokenProvider( |
||||||
|
Lazy<ILykosAuthApi> lazyLykosAuthApi, |
||||||
|
ISecretsManager secretsManager |
||||||
|
) |
||||||
|
{ |
||||||
|
// Lazy as instantiating requires the current class to be instantiated. |
||||||
|
this.lazyLykosAuthApi = lazyLykosAuthApi; |
||||||
|
this.secretsManager = secretsManager; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public async Task<string> GetAccessTokenAsync() |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
return secrets.LykosAccount?.AccessToken ?? ""; |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc /> |
||||||
|
public async Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync() |
||||||
|
{ |
||||||
|
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken)) |
||||||
|
{ |
||||||
|
throw new InvalidOperationException("No refresh token found"); |
||||||
|
} |
||||||
|
|
||||||
|
var lykosAuthApi = lazyLykosAuthApi.Value; |
||||||
|
var newTokens = await lykosAuthApi |
||||||
|
.PostLoginRefresh(new PostLoginRefreshRequest(secrets.LykosAccount.RefreshToken)) |
||||||
|
.ConfigureAwait(false); |
||||||
|
|
||||||
|
secrets = secrets with { LykosAccount = newTokens }; |
||||||
|
|
||||||
|
await secretsManager.SaveAsync(secrets).ConfigureAwait(false); |
||||||
|
|
||||||
|
return (newTokens.AccessToken, newTokens.RefreshToken); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,69 @@ |
|||||||
|
using System.Net; |
||||||
|
using System.Net.Http.Headers; |
||||||
|
using NLog; |
||||||
|
using Polly; |
||||||
|
using Polly.Retry; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Api; |
||||||
|
|
||||||
|
public class TokenAuthHeaderHandler : DelegatingHandler |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
|
||||||
|
private readonly AsyncRetryPolicy<HttpResponseMessage> policy; |
||||||
|
private readonly ITokenProvider tokenProvider; |
||||||
|
|
||||||
|
public TokenAuthHeaderHandler(ITokenProvider tokenProvider) |
||||||
|
{ |
||||||
|
this.tokenProvider = tokenProvider; |
||||||
|
|
||||||
|
policy = Policy |
||||||
|
.HandleResult<HttpResponseMessage>( |
||||||
|
r => |
||||||
|
r.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden |
||||||
|
&& r.RequestMessage?.Headers.Authorization |
||||||
|
is { Scheme: "Bearer", Parameter: not null } |
||||||
|
) |
||||||
|
.RetryAsync( |
||||||
|
async (result, _) => |
||||||
|
{ |
||||||
|
var oldToken = ObjectHash.GetStringSignature( |
||||||
|
await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false) |
||||||
|
); |
||||||
|
Logger.Info( |
||||||
|
"Refreshing access token for status ({StatusCode})", |
||||||
|
result.Result.StatusCode |
||||||
|
); |
||||||
|
var (newToken, _) = await tokenProvider |
||||||
|
.RefreshTokensAsync() |
||||||
|
.ConfigureAwait(false); |
||||||
|
|
||||||
|
Logger.Info( |
||||||
|
"Access token refreshed: {OldToken} -> {NewToken}", |
||||||
|
ObjectHash.GetStringSignature(oldToken), |
||||||
|
ObjectHash.GetStringSignature(newToken) |
||||||
|
); |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
// InnerHandler must be left as null when using DI, but must be assigned a value when |
||||||
|
// using RestService.For<IMyApi> |
||||||
|
// InnerHandler = new HttpClientHandler(); |
||||||
|
} |
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync( |
||||||
|
HttpRequestMessage request, |
||||||
|
CancellationToken cancellationToken |
||||||
|
) |
||||||
|
{ |
||||||
|
return policy.ExecuteAsync(async () => |
||||||
|
{ |
||||||
|
var accessToken = await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false); |
||||||
|
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); |
||||||
|
|
||||||
|
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
using Semver; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Extensions; |
||||||
|
|
||||||
|
public static class SemVersionExtensions |
||||||
|
{ |
||||||
|
public static string ToDisplayString(this SemVersion version) |
||||||
|
{ |
||||||
|
var versionString = $"{version.Major}.{version.Minor}.{version.Patch}"; |
||||||
|
|
||||||
|
// Add the build metadata if we have pre-release information |
||||||
|
if (version.PrereleaseIdentifiers.Count > 0) |
||||||
|
{ |
||||||
|
versionString += $"-{version.Prerelease}"; |
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(version.Metadata)) |
||||||
|
{ |
||||||
|
// First 7 characters of the commit hash |
||||||
|
versionString += $"+{version.Metadata[..7]}"; |
||||||
|
} |
||||||
|
} |
||||||
|
return versionString; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
using System.Web; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Extensions; |
||||||
|
|
||||||
|
public static class UriExtensions |
||||||
|
{ |
||||||
|
public static Uri WithQuery(this Uri uri, string key, string value) |
||||||
|
{ |
||||||
|
var builder = new UriBuilder(uri); |
||||||
|
var query = HttpUtility.ParseQueryString(builder.Query); |
||||||
|
query[key] = value; |
||||||
|
builder.Query = query.ToString() ?? string.Empty; |
||||||
|
return builder.Uri; |
||||||
|
} |
||||||
|
|
||||||
|
public static Uri Append(this Uri uri, params string[] paths) |
||||||
|
{ |
||||||
|
return new Uri( |
||||||
|
paths.Aggregate( |
||||||
|
uri.AbsoluteUri, |
||||||
|
(current, path) => $"{current.TrimEnd('/')}/{path.TrimStart('/')}" |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Helper; |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Lazy instance of a DI service. |
||||||
|
/// </summary> |
||||||
|
public class LazyInstance<T> : Lazy<T> |
||||||
|
where T : notnull |
||||||
|
{ |
||||||
|
public LazyInstance(IServiceProvider serviceProvider) |
||||||
|
: base(serviceProvider.GetRequiredService<T>) { } |
||||||
|
} |
||||||
|
|
||||||
|
public static class LazyInstanceServiceExtensions |
||||||
|
{ |
||||||
|
/// <summary> |
||||||
|
/// Register <see cref="LazyInstance{T}"/> to be used when resolving <see cref="Lazy{T}"/> instances. |
||||||
|
/// </summary> |
||||||
|
public static IServiceCollection AddLazyInstance(this IServiceCollection services) |
||||||
|
{ |
||||||
|
services.AddTransient(typeof(Lazy<>), typeof(LazyInstance<>)); |
||||||
|
return services; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
using StabilityMatrix.Core.Models.Api.CivitTRPC; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Core.Models.Api; |
||||||
|
|
||||||
|
public class CivitAccountStatusUpdateEventArgs : EventArgs |
||||||
|
{ |
||||||
|
public static CivitAccountStatusUpdateEventArgs Disconnected { get; } = new(); |
||||||
|
|
||||||
|
public bool IsConnected { get; init; } |
||||||
|
|
||||||
|
public CivitUserProfileResponse? UserProfile { get; init; } |
||||||
|
|
||||||
|
public string? UsernameWithParentheses => |
||||||
|
string.IsNullOrEmpty(UserProfile?.Username) ? null : $"({UserProfile.Username})"; |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
namespace StabilityMatrix.Core.Models.Api.CivitTRPC; |
||||||
|
|
||||||
|
public record CivitApiTokens(string ApiToken, string Username); |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue