Started adding capture page logic

This commit is contained in:
2025-03-05 19:32:18 +01:00
parent c2f89b1b09
commit 44da4932aa
17 changed files with 458 additions and 97 deletions

View File

@@ -1,8 +1,11 @@
namespace WorkTime.Mobile; using MauiIcons.Core;
namespace WorkTime.Mobile;
public partial class App : Application { public partial class App : Application {
public App() { public App() {
InitializeComponent(); InitializeComponent();
_ = new MauiIcon();
} }
protected override Window CreateWindow(IActivationState? activationState) { protected override Window CreateWindow(IActivationState? activationState) {

View File

@@ -3,13 +3,24 @@
x:Class="WorkTime.Mobile.AppShell" x:Class="WorkTime.Mobile.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:WorkTime.Mobile" xmlns:pages="clr-namespace:WorkTime.Mobile.Views.Pages"
Shell.FlyoutBehavior="Flyout" xmlns:icons="http://www.aathifmahir.com/dotnet/2022/maui/icons"
Title="WorkTime.Mobile"> FlyoutBehavior="Disabled"
Title="Zeiterfassung">
<ShellContent <Shell.Resources>
Title="Home" <ResourceDictionary>
ContentTemplate="{DataTemplate local:MainPage}" <FontImageSource x:Key="CaptureIcon" Glyph="{icons:Material Schedule}" />
Route="MainPage" /> </ResourceDictionary>
</Shell.Resources>
<TabBar>
<ShellContent
Title="Erfassen"
Icon="{StaticResource CaptureIcon}"
ContentTemplate="{DataTemplate pages:CapturePage}"/>
</TabBar>
</Shell> </Shell>

View File

@@ -0,0 +1,17 @@
using WorkTime.Shared.Models;
namespace WorkTime.Mobile;
public static class Extensions {
public static string GetActionName(this TimeEntryType type) {
return type switch {
TimeEntryType.Login => "Einstempeln",
TimeEntryType.Logout => "Ausstempeln",
TimeEntryType.LoginDrive => "Dienstreise starten",
TimeEntryType.LogoutDrive => "Dienstreise beenden",
_ => string.Empty
};
}
}

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="WorkTime.Mobile.MainPage">
<ScrollView>
<VerticalStackLayout
Padding="30,0"
Spacing="25">
<Image
Source="dotnet_bot.png"
HeightRequest="185"
Aspect="AspectFit"
SemanticProperties.Description="dot net bot in a hovercraft number nine" />
<Label
Text="Hello, World!"
Style="{StaticResource Headline}"
SemanticProperties.HeadingLevel="Level1" />
<Label
Text="Welcome to &#10;.NET Multi-platform App UI"
Style="{StaticResource SubHeadline}"
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description="Welcome to dot net Multi platform App U I" />
<Button
x:Name="CounterBtn"
Text="Click me"
SemanticProperties.Hint="Counts the number of times you click"
Clicked="OnCounterClicked"
HorizontalOptions="Fill" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -1,20 +0,0 @@
namespace WorkTime.Mobile;
public partial class MainPage : ContentPage {
int count = 0;
public MainPage() {
InitializeComponent();
}
private void OnCounterClicked(object sender, EventArgs e) {
count++;
if (count == 1)
CounterBtn.Text = $"Clicked {count} time";
else
CounterBtn.Text = $"Clicked {count} times";
SemanticScreenReader.Announce(CounterBtn.Text);
}
}

View File

@@ -1,7 +1,8 @@
using System.Net.Http.Headers; using MauiIcons.Material;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using WorkTime.Mobile.Repositories; using WorkTime.Mobile.Repositories;
using WorkTime.Mobile.Services; using WorkTime.Mobile.Services;
using WorkTime.Mobile.ViewModels;
using WorkTime.Shared.Repositories; using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services; using WorkTime.Shared.Services;
@@ -9,7 +10,7 @@ namespace WorkTime.Mobile;
public static class MauiProgram { public static class MauiProgram {
public static string BackendUrl { get; private set; } = string.Empty; //TODO: Set production endpoint private static string BackendUrl { get; set; } = string.Empty; //TODO: Set production endpoint
public static MauiApp CreateMauiApp() { public static MauiApp CreateMauiApp() {
var builder = MauiApp.CreateBuilder(); var builder = MauiApp.CreateBuilder();
@@ -18,25 +19,37 @@ public static class MauiProgram {
.ConfigureFonts(fonts => { .ConfigureFonts(fonts => {
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
}); })
.UseMaterialMauiIcons();
builder.Services.AddScoped<HttpClient>(provider => { builder.Services.AddSqlite<DatabaseContext>($"Filename={Path.Combine(FileSystem.AppDataDirectory, "data.db")}");
var auth = provider.GetRequiredService<IAuthService>(); builder.Services.AddSingleton(SecureStorage.Default);
builder.Services.AddScoped<HttpClient>(_ => {
var client = new HttpClient(); var client = new HttpClient();
client.BaseAddress = new Uri(BackendUrl); client.BaseAddress = new Uri(BackendUrl);
client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(auth.GetCurrentUserId().Result.ToString());
return client; return client;
}); });
builder.Services.AddKeyedScoped<IEntryRepository, ServerEntryRepository>("server"); builder.Services.AddKeyedScoped<IEntryRepository, ServerEntryRepository>("server");
builder.Services.AddKeyedScoped<IEntryRepository, ClientEntryRepository>("client"); builder.Services.AddKeyedScoped<IEntryRepository, ClientEntryRepository>("client");
builder.Services.AddKeyedScoped<IHttpService, InsecureHttpService>("insecure");
builder.Services.AddScoped<IHttpService, HttpService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<EntryService>();
builder.Services.AddTransient<CaptureViewModel>();
#if DEBUG #if DEBUG
builder.Logging.AddDebug(); builder.Logging.AddDebug();
BackendUrl = "https://localhost:7091/"; BackendUrl = "https://localhost:7091/";
#endif #endif
return builder.Build(); var app = builder.Build();
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
db.Database.EnsureCreated();
return app;
} }
} }

View File

@@ -35,5 +35,15 @@ public class ClientEntryRepository(DatabaseContext context) : IEntryRepository {
context.Entries.Remove(entry); context.Entries.Remove(entry);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
public async Task ReplaceEntries(IEnumerable<TimeEntry> entries, DateOnly date) {
var oldEntries = await context.Entries
.Where(entry => DateOnly.FromDateTime(entry.Timestamp) == date)
.ToArrayAsync();
context.Entries.RemoveRange(oldEntries);
await context.Entries.AddRangeAsync(entries);
await context.SaveChangesAsync();
}
} }

View File

@@ -1,35 +1,29 @@
using System.Net.Http.Json; using WorkTime.Mobile.Services;
using WorkTime.Shared.Models; using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories; using WorkTime.Shared.Repositories;
namespace WorkTime.Mobile.Repositories; namespace WorkTime.Mobile.Repositories;
public class ServerEntryRepository(HttpClient client) : IEntryRepository { public class ServerEntryRepository(IHttpService client) : IEntryRepository {
public async Task<TimeEntry[]> GetAllEntries(Guid owner) { public async Task<TimeEntry[]> GetAllEntries(Guid owner) {
var response = await client.GetAsync($"entries/{owner}"); var response = await client.SendRequest<TimeEntry[]>(HttpMethod.Get, $"entries/{owner}");
if (!response.IsSuccessStatusCode) return []; return response.Result ?? [];
return await response.Content.ReadFromJsonAsync<TimeEntry[]>() ?? [];
} }
public async Task<TimeEntry[]> GetEntries(Guid owner, DateOnly date) { public async Task<TimeEntry[]> GetEntries(Guid owner, DateOnly date) {
var response = await client.GetAsync($"entries/{owner}/{date.ToDateTime(TimeOnly.MinValue)}"); var response = await client.SendRequest<TimeEntry[]>(HttpMethod.Get, $"entries/{owner}/{date.ToDateTime(TimeOnly.MinValue)}");
if (!response.IsSuccessStatusCode) return []; return response.Result ?? [];
}
return await response.Content.ReadFromJsonAsync<TimeEntry[]>() ?? []; public Task<TimeEntry?> GetEntry(int id) => Task.FromResult<TimeEntry?>(null);
}
public Task<TimeEntry?> GetEntry(int id) {
throw new NotImplementedException();
}
public async Task AddEntry(TimeEntry entry) { public async Task AddEntry(TimeEntry entry) {
await client.PostAsJsonAsync("entries", entry); await client.SendRequest(HttpMethod.Post, "entries", entry);
} }
public async Task DeleteEntry(TimeEntry entry) { public async Task DeleteEntry(TimeEntry entry) {
await client.DeleteAsync($"entries/{entry.Id}"); await client.SendRequest(HttpMethod.Delete, $"entries/{entry.Id}");
} }
} }

View File

@@ -2,7 +2,7 @@
namespace WorkTime.Mobile.Services; namespace WorkTime.Mobile.Services;
public class AuthService(ISecureStorage storage) : IAuthService { public class AuthService(ISecureStorage storage, [FromKeyedServices("insecure")] IHttpService httpService) : IAuthService {
public async Task<bool> IsAuthenticated() { public async Task<bool> IsAuthenticated() {
var value = await storage.GetAsync(IAuthService.HeaderName); var value = await storage.GetAsync(IAuthService.HeaderName);
@@ -13,13 +13,8 @@ public class AuthService(ISecureStorage storage) : IAuthService {
var value = await storage.GetAsync(IAuthService.HeaderName); var value = await storage.GetAsync(IAuthService.HeaderName);
if (string.IsNullOrWhiteSpace(value)) { if (string.IsNullOrWhiteSpace(value)) {
var client = new HttpClient(); var response = await httpService.SendRequest<string>(HttpMethod.Get, "auth/register");
client.BaseAddress = new Uri(MauiProgram.BackendUrl); value = response.Result ?? Guid.NewGuid().ToString();
var response = await client.GetAsync("auth/register");
if (!response.IsSuccessStatusCode) return Guid.Empty;
value = await response.Content.ReadAsStringAsync();
} }
return Guid.Parse(value); return Guid.Parse(value);

View File

@@ -0,0 +1,66 @@
using WorkTime.Mobile.Repositories;
using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services;
namespace WorkTime.Mobile.Services;
public class EntryService(
[FromKeyedServices("server")] IEntryRepository serverRepository,
[FromKeyedServices("client")] IEntryRepository clientRepository,
IAuthService authService) {
public async IAsyncEnumerable<UpdatingEntriesResponse> GetEntries(DateOnly date) {
var userId = await authService.GetCurrentUserId();
var clientEntries = await clientRepository.GetEntries(userId, date);
yield return new() {
NewBatch = true
};
foreach (var entry in clientEntries) {
yield return new() {
Entry = entry
};
}
var serverEntries = await serverRepository.GetEntries(userId, date);
if (serverEntries.Length == 0) yield break;
yield return new() {
NewBatch = true
};
foreach (var entry in serverEntries) {
yield return new() {
Entry = entry
};
}
await ((ClientEntryRepository)clientRepository).ReplaceEntries(serverEntries, date);
}
public async Task AddEntry(TimeEntry entry) {
var userId = await authService.GetCurrentUserId();
entry.Owner = userId;
await Task.WhenAll(
clientRepository.AddEntry(entry),
serverRepository.AddEntry(entry)
);
}
public async Task DeleteEntry(TimeEntry entry) {
await Task.WhenAll(
clientRepository.DeleteEntry(entry),
serverRepository.DeleteEntry(entry)
);
}
}
public readonly struct UpdatingEntriesResponse {
public bool NewBatch { get; init; }
public TimeEntry? Entry { get; init; }
}

View File

@@ -0,0 +1,101 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
using System.Text.Json;
using WorkTime.Shared.Services;
namespace WorkTime.Mobile.Services;
public interface IHttpService {
Task<HttpResponse<TResult>> SendRequest<TResult>(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string uri, object? body = null);
Task<HttpResponse> SendRequest(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string uri, object? body = null);
}
internal class HttpService(HttpClient client, IAuthService authService) : IHttpService {
private InsecureHttpService? _service;
public async Task<HttpResponse<TResult>> SendRequest<TResult>(HttpMethod method, string uri, object? body = null) {
var service = await GetInternalService();
return await service.SendRequest<TResult>(method, uri, body);
}
public async Task<HttpResponse> SendRequest(HttpMethod method, string uri, object? body = null) {
var service = await GetInternalService();
return await service.SendRequest(method, uri, body);
}
private async Task<IHttpService> GetInternalService() {
if (_service is null) {
var id = await authService.GetCurrentUserId();
client.DefaultRequestHeaders.Add(IAuthService.HeaderName, id.ToString());
_service = new InsecureHttpService(client);
}
return _service;
}
}
internal class InsecureHttpService(HttpClient client) : IHttpService {
public async Task<HttpResponse<TResult>> SendRequest<TResult>(HttpMethod method, string uri, object? body = null) {
try {
var request = new HttpRequestMessage(method, uri);
if (body is not null)
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
return new HttpResponse<TResult> {
Result = typeof(TResult) == typeof(string) ? (TResult)(object)responseContent : JsonSerializer.Deserialize<TResult>(responseContent),
ResponseCode = response.StatusCode,
IsSuccessful = response.IsSuccessStatusCode
};
}
catch (Exception) {
return new() {
IsSuccessful = false,
ResponseCode = HttpStatusCode.InternalServerError
};
}
}
public async Task<HttpResponse> SendRequest(HttpMethod method, string uri, object? body = null) {
try {
var request = new HttpRequestMessage(method, uri);
if (body is not null)
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
return new HttpResponse {
ResponseCode = response.StatusCode,
IsSuccessful = response.IsSuccessStatusCode
};
}
catch (Exception) {
return new() {
IsSuccessful = false,
ResponseCode = HttpStatusCode.InternalServerError
};
}
}
}
public readonly struct HttpResponse<TResult> {
public TResult? Result { get; init; }
public HttpStatusCode ResponseCode { get; init; }
public bool IsSuccessful { get; init; }
public static implicit operator TResult?(HttpResponse<TResult> response) {
return response.Result;
}
}
public readonly struct HttpResponse {
public HttpStatusCode ResponseCode { get; init; }
public bool IsSuccessful { get; init; }
}

View File

@@ -0,0 +1,77 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WorkTime.Mobile.Services;
using WorkTime.Shared.Models;
using WorkTime.Shared.Services;
namespace WorkTime.Mobile.ViewModels;
public partial class CaptureViewModel(EntryService entryService, IAuthService authService) : ObservableObject {
[ObservableProperty]
public partial ObservableCollection<TimeEntry> Entries { get; set; } = new();
[ObservableProperty]
public partial TimeEntryType CurrentAction { get; set; } = TimeEntryType.Login;
[ObservableProperty]
public partial bool CurrentlyMoba { get; set; }
public string CurrentActionName => CurrentAction.GetActionName();
[RelayCommand]
private async Task OnDateSelected(DateOnly date) {
await foreach (var entryResponse in entryService.GetEntries(date)) {
if (entryResponse.NewBatch)
Entries.Clear();
if (entryResponse.Entry is not null)
Entries.Add(entryResponse.Entry);
}
UpdateCurrentAction();
}
[RelayCommand]
private async Task RegisterEntry(TimeEntry? entry = null) {
entry ??= new TimeEntry {
Type = CurrentAction,
IsMoba = CurrentlyMoba,
Owner = await authService.GetCurrentUserId()
};
var insertIndex = Entries.Index()
.LastOrDefault(e => e.Item.Timestamp < entry.Timestamp)
.Index;
if (Entries.Count == insertIndex + 1 || Entries.Count == 0)
Entries.Add(entry);
else Entries.Insert(insertIndex + 1, entry);
await entryService.AddEntry(entry);
UpdateCurrentAction();
}
private void UpdateCurrentAction() {
if (Entries.Count == 0) {
CurrentAction = TimeEntryType.Login;
CurrentlyMoba = false;
return;
}
var lastEntry = Entries[^1];
CurrentAction = lastEntry.Type switch {
TimeEntryType.Login => TimeEntryType.Logout,
TimeEntryType.Logout => TimeEntryType.Login,
TimeEntryType.LoginDrive => TimeEntryType.LogoutDrive,
TimeEntryType.LogoutDrive => TimeEntryType.Login,
_ => TimeEntryType.Login
};
CurrentlyMoba = lastEntry is { Type: TimeEntryType.Login, IsMoba: true };
}
partial void OnCurrentActionChanged(TimeEntryType value) {
OnPropertyChanged(nameof(CurrentActionName));
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:components="clr-namespace:WorkTime.Mobile.Views.Components"
x:Class="WorkTime.Mobile.Views.Components.DateSelector"
x:DataType="components:DateSelector"
x:Name="Component">
<Grid ColumnDefinitions="*,Auto">
<Label
Grid.Column="0"
Text="Tag"
VerticalOptions="Center"/>
<DatePicker
Grid.Column="1"
Date="{Binding CurrentDate, Source={x:Reference Component}}"
MaximumDate="{Binding MaxDate, Source={x:Reference Component}}"
DateSelected="OnDateSelected"/>
</Grid>
</ContentView>

View File

@@ -0,0 +1,47 @@
using CommunityToolkit.Mvvm.Input;
namespace WorkTime.Mobile.Views.Components;
public partial class DateSelector : ContentView {
public static readonly BindableProperty CurrentDateProperty = BindableProperty.Create(
nameof(CurrentDate),
typeof(DateTime),
typeof(DateSelector),
DateTime.Now);
public static readonly BindableProperty MaxDateProperty = BindableProperty.Create(
nameof(MaxDate),
typeof(DateTime),
typeof(DateSelector),
DateTime.Now);
public static readonly BindableProperty CommandProperty = BindableProperty.Create(
nameof(Command),
typeof(IRelayCommand<DateOnly>),
typeof(DateSelector));
public DateTime CurrentDate {
get => (DateTime)GetValue(CurrentDateProperty);
set => SetValue(CurrentDateProperty, value);
}
public DateTime MaxDate {
get => (DateTime)GetValue(MaxDateProperty);
set => SetValue(MaxDateProperty, value);
}
public IRelayCommand<DateOnly>? Command {
get => (IRelayCommand<DateOnly>)GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
public DateSelector() {
InitializeComponent();
}
private void OnDateSelected(object? sender, DateChangedEventArgs e) {
var date = DateOnly.FromDateTime(CurrentDate);
Command?.Execute(date);
}
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:components="clr-namespace:WorkTime.Mobile.Views.Components"
xmlns:viewModels="clr-namespace:WorkTime.Mobile.ViewModels"
xmlns:models="clr-namespace:WorkTime.Shared.Models;assembly=WorkTime.Shared"
x:Class="WorkTime.Mobile.Views.Pages.CapturePage"
x:DataType="viewModels:CaptureViewModel"
Padding="24, 0, 24, 24">
<Grid RowDefinitions="Auto,*,Auto">
<components:DateSelector
Grid.Row="0"
Command="{Binding DateSelectedCommand}"/>
<CollectionView
Grid.Row="1"
ItemsSource="{Binding Entries}">
<CollectionView.ItemTemplate>
<DataTemplate
x:DataType="models:TimeEntry">
<HorizontalStackLayout Spacing="5">
<Label Text="{Binding Timestamp}" />
<Label Text="{Binding Type}" />
</HorizontalStackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Button
Grid.Row="2"
Text="{Binding CurrentActionName}"
Command="{Binding RegisterEntryCommand}"/>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WorkTime.Mobile.ViewModels;
namespace WorkTime.Mobile.Views.Pages;
public partial class CapturePage : ContentPage {
public CapturePage(CaptureViewModel model) {
InitializeComponent();
BindingContext = model;
}
}

View File

@@ -19,12 +19,14 @@
<SingleProject>true</SingleProject> <SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- Display name --> <!-- Display name -->
<ApplicationTitle>WorkTime.Mobile</ApplicationTitle> <ApplicationTitle>Zeiterfassung</ApplicationTitle>
<!-- App Identifier --> <!-- App Identifier -->
<ApplicationId>com.companyname.worktime.mobile</ApplicationId> <ApplicationId>de.leon-hoppe.worktime.mobile</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
@@ -60,6 +62,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AathifMahir.Maui.MauiIcons.Material" Version="4.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)"/> <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)"/>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0"/> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0"/>