14 Commits

Author SHA1 Message Date
7bd0e8767b Version 1.1.0
Umstellung auf MVVM
Umstellung auf .NET 10
Weniger API-Requests
2025-12-30 13:48:15 +01:00
a9467e729d Refactor StundePage and ViewModels: adjust grid padding, clean up commented code, add SelectEntryCommand, and improve navigation logic. 2025-12-30 12:07:01 +01:00
933ddd9874 Refactor StundePage and StundenPage layouts: adjust grid structure, improve margin handling, and enhance visuals with new binding and triggers. 2025-12-29 16:18:04 +01:00
a4f586d445 Refactor StundenViewModel: simplify Title property, make RefreshProperties public, and update startup logic in StundenPage to refresh data after login. 2025-12-26 17:39:12 +01:00
5148280c36 Refactor: Remove ITokenProvider and SettingsTokenProvider; update StundePage layout and optimize dependency injection configuration. 2025-12-26 17:04:52 +01:00
e2ffc24131 Refactor LoginViewModel to use IAppSettings; improve settings management and update dependency injection. 2025-12-26 13:54:51 +01:00
4d5b093ea0 Refactor: Remove GlobalVar and replace with IAppSettings; restructure affected infrastructure, services, and view models for dependency injection. 2025-12-26 11:43:20 +01:00
1ee0fc61f6 HoursDto: Update 2025-12-25 19:19:17 +01:00
c6fd58a290 Reenabled deletion of Time-Entrys 2025-12-25 19:10:36 +01:00
656d39f43e Less requests to get Data faster
Load settings with `GetEntryWithSettingsAsync`, update `Hours` and `ViewModels`.
2025-12-25 11:39:44 +01:00
15856d0dd0 Refactor StundePage to MVVM with DI 2025-12-25 09:20:14 +01:00
8da8734065 Add SocketsHttpHandler 2025-12-25 09:03:01 +01:00
cd4eae34c3 Projekt and Gemeinde are required if active 2025-12-19 15:26:26 +01:00
52815d7e21 .net 10 2025-12-19 15:25:59 +01:00
29 changed files with 550 additions and 790 deletions

View File

@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using System.Linq;
using Microsoft.Maui.Controls;
namespace Jugenddienst_Stunden.Converter;
public class AnyTrueMultiConverter : IMultiValueConverter {
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) {
foreach (object value in values) {
if (value is bool b && b) {
return true;
}
}
return false;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) {
throw new NotSupportedException();
}
}

View File

@@ -13,7 +13,7 @@ internal sealed class ApiClient : IApiClient {
private readonly ApiOptions _options; private readonly ApiOptions _options;
private readonly IAppSettings _settings; private readonly IAppSettings _settings;
public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) { public ApiClient(HttpClient http, ApiOptions options, IAppSettings settings) {
_http = http; _http = http;
_options = options; _options = options;
_settings = settings; _settings = settings;
@@ -47,9 +47,11 @@ internal sealed class ApiClient : IApiClient {
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null, public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
IDictionary<string, string?>? query = null, CancellationToken ct = default) { IDictionary<string, string?>? query = null, CancellationToken ct = default) {
// Absolute URI aus aktuellem SettingsBaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen. // Absolute URI aus aktuellem SettingsBaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen.
var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query); var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query);
using var req = new HttpRequestMessage(method, uri); using var req = new HttpRequestMessage(method, uri);
// Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist) // Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist)
// Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten. // Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten.
// AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen. // AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen.
@@ -73,10 +75,17 @@ internal sealed class ApiClient : IApiClient {
using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false); var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) {
var message = req.Method + ": " + req.RequestUri + " nicht gefunden";
throw ApiException.From(res.StatusCode, message);
}
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
throw ApiException.From(res.StatusCode, TryGetMessage(text), text); throw ApiException.From(res.StatusCode, TryGetMessage(text), text);
if (res.StatusCode != System.Net.HttpStatusCode.OK) { if (res.StatusCode != System.Net.HttpStatusCode.OK) {
// Verhalten wie in BaseFunc: bei Fehlerstatus -> "message" aus Body lesen und mit dessen Inhalt eine Exception werfen. // Verhalten wie in BaseFunc: bei Fehlerstatus -> "message" aus Body lesen und mit dessen Inhalt eine Exception werfen.
try { try {
var options = new JsonDocumentOptions { AllowTrailingCommas = true }; var options = new JsonDocumentOptions { AllowTrailingCommas = true };
@@ -127,24 +136,44 @@ internal sealed class ApiClient : IApiClient {
throw new InvalidOperationException( throw new InvalidOperationException(
"ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl')."); "ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl').");
// Basis muss absolut sein (z. B. https://host/appapi/)
var baseUri = new Uri(baseUrl, UriKind.Absolute);
var normalizedBase = baseUrl.Trim();
// Pfad relativ zur Basis aufbauen // Pfad relativ zur Basis aufbauen
string relativePath = path ?? string.Empty; string relativePath = path ?? string.Empty;
if (relativePath.StartsWith('/'))
relativePath = relativePath.TrimStart('/');
if (query is not null && query.Count > 0) { if (query is not null && query.Count > 0) {
if (normalizedBase.EndsWith('/'))
normalizedBase = normalizedBase.TrimEnd('/');
var sb = new StringBuilder(relativePath); var sb = new StringBuilder(relativePath);
sb.Append(relativePath.Contains('?') ? '&' : '?'); sb.Append(relativePath.Contains('?') ? '&' : '?');
sb.Append(string.Join('&', query sb.Append(string.Join('&', query
.Where(kv => kv.Value is not null) .Where(kv => kv.Value is not null)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}"))); .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
relativePath = sb.ToString(); relativePath = sb.ToString();
} else {
if (!normalizedBase.EndsWith('/'))
normalizedBase += "/";
} }
// Wenn path bereits absolut ist, direkt verwenden var baseUri = new Uri(normalizedBase, UriKind.Absolute);
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
return absoluteFromPath;
return new Uri(baseUri, relativePath); // Wenn path bereits absolut ist, direkt verwenden
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath)) {
var uriString = absoluteFromPath.ToString();
if (uriString.EndsWith('/') && absoluteFromPath.AbsolutePath.Length > 1)
return new Uri(uriString.TrimEnd('/'));
return absoluteFromPath;
}
var result = new Uri(baseUri, relativePath);
var finalUriString = result.ToString();
if (finalUriString.EndsWith('/') && result.AbsolutePath.Length > 1)
return new Uri(finalUriString.TrimEnd('/'));
return result;
} }
} }

View File

@@ -1,8 +1,18 @@
using Jugenddienst_Stunden.Interfaces; using Jugenddienst_Stunden.Interfaces;
using Jugenddienst_Stunden.Types;
namespace Jugenddienst_Stunden.Infrastructure; namespace Jugenddienst_Stunden.Infrastructure;
internal sealed class PreferencesAppSettings : IAppSettings { /// <summary>
/// Represents the application settings and provides access to user preferences
/// such as API URL, API key, employee ID, and personal details.
/// </summary>
/// <remarks>
/// The <c>AppSettings</c> class implements the <c>IAppSettings</c> interface and manages
/// persistent configuration settings needed for the application.
/// These settings include preferences like API configuration and user identification details.
/// </remarks>
internal sealed class AppSettings : IAppSettings {
public string ApiUrl { public string ApiUrl {
get => Preferences.Default.Get("apiUrl", ""); get => Preferences.Default.Get("apiUrl", "");
set => Preferences.Default.Set("apiUrl", value); set => Preferences.Default.Set("apiUrl", value);
@@ -27,4 +37,5 @@ internal sealed class PreferencesAppSettings : IAppSettings {
get => Preferences.Default.Get("surname", "Eingeloggt"); get => Preferences.Default.Get("surname", "Eingeloggt");
set => Preferences.Default.Set("surname", value); set => Preferences.Default.Set("surname", value);
} }
public Settings? Settings { get; set; }
} }

View File

@@ -0,0 +1,29 @@
using Jugenddienst_Stunden.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;
namespace Jugenddienst_Stunden.Infrastructure;
internal static class HttpClientRegistration {
/// <summary>
/// Registriert den ApiClient mit einem SocketsHttpHandler als primären MessageHandler.
/// Vermeidet den Android-spezifischen Cast-Fehler in Xamarin.Android.Net.AndroidMessageHandler.
///</summary>
public static IServiceCollection AddApiHttpClient(this IServiceCollection services, ApiOptions options) {
if (services is null)
throw new ArgumentNullException(nameof(services));
if (options is null)
throw new ArgumentNullException(nameof(options));
// ApiOptions als Singleton bereitstellen (kann nach Bedarf angepasst werden)
services.AddSingleton(options);
// HttpClient für ApiClient registrieren und einen SocketsHttpHandler verwenden.
// SocketsHttpHandler vermeidet das problematische Casting, das bei AndroidMessageHandler
// zur InvalidCastException (URLConnectionInvoker -> HttpURLConnection) führt.
services.AddHttpClient<IApiClient, ApiClient>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler());
return services;
}
}

View File

@@ -1,10 +0,0 @@
using Jugenddienst_Stunden.Interfaces;
namespace Jugenddienst_Stunden.Infrastructure;
internal sealed class SettingsTokenProvider : ITokenProvider {
private readonly IAppSettings _settings;
public SettingsTokenProvider(IAppSettings settings) => _settings = settings;
public string GetToken() => _settings.ApiKey;
}

View File

@@ -1,7 +0,0 @@
using Jugenddienst_Stunden.Interfaces;
namespace Jugenddienst_Stunden.Infrastructure;
internal sealed class GlobalVarTokenProvider : ITokenProvider {
public string? GetToken() => Models.GlobalVar.ApiKey;
}

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Jugenddienst_Stunden.Interfaces; namespace Jugenddienst_Stunden.Interfaces;
internal interface IAlertService { public interface IAlertService {
event EventHandler<string> AlertRaised; event EventHandler<string> AlertRaised;
void Raise(string message); void Raise(string message);
} }

View File

@@ -1,5 +1,8 @@
namespace Jugenddienst_Stunden.Interfaces; namespace Jugenddienst_Stunden.Interfaces;
/// <summary>
/// Defines methods for making HTTP requests to a specified API.
/// </summary>
internal interface IApiClient { internal interface IApiClient {
Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default); Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default);

View File

@@ -1,5 +1,10 @@
using Jugenddienst_Stunden.Types;
namespace Jugenddienst_Stunden.Interfaces; namespace Jugenddienst_Stunden.Interfaces;
/// <summary>
/// Represents the application settings required for configuration and operation of the application.
/// </summary>
public interface IAppSettings { public interface IAppSettings {
string ApiUrl { get; set; } string ApiUrl { get; set; }
string ApiKey { get; set; } string ApiKey { get; set; }
@@ -7,4 +12,6 @@ public interface IAppSettings {
int EmployeeId { get; set; } int EmployeeId { get; set; }
string Name { get; set; } string Name { get; set; }
string Surname { get; set; } string Surname { get; set; }
Settings? Settings { get; set; }
} }

View File

@@ -13,4 +13,5 @@ public interface IHoursService {
Task<DayTime> GetEntryAsync(int id); Task<DayTime> GetEntryAsync(int id);
Task<DayTime> SaveEntryAsync(DayTime stunde); Task<DayTime> SaveEntryAsync(DayTime stunde);
Task DeleteEntryAsync(DayTime stunde); Task DeleteEntryAsync(DayTime stunde);
Task<(DayTime dayTime, Settings settings, List<DayTime> existingDayTimes)> GetEntryWithSettingsAsync(int id);
} }

View File

@@ -1,5 +0,0 @@
namespace Jugenddienst_Stunden.Interfaces;
internal interface ITokenProvider {
string? GetToken();
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<!-- <TargetFrameworks>net8.0-maccatalyst;net9.0-android35.0</TargetFrameworks> --> <!-- <TargetFrameworks>net8.0-maccatalyst;net9.0-android35.0</TargetFrameworks> -->
<TargetFrameworks>net9.0-android35.0</TargetFrameworks> <TargetFrameworks>net10.0-android36.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET --> <!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> --> <!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
@@ -109,8 +109,8 @@
<SymbolPackageFormat>snupkg</SymbolPackageFormat> <SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
<ProduceReferenceAssembly>False</ProduceReferenceAssembly> <ProduceReferenceAssembly>False</ProduceReferenceAssembly>
<AssemblyVersion>1.0.9</AssemblyVersion> <AssemblyVersion>1.1.0</AssemblyVersion>
<FileVersion>1.0.9</FileVersion> <FileVersion>1.1.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net8.0-android|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net8.0-android|AnyCPU'">
@@ -176,76 +176,96 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.26100.0</TargetFrameworks> <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.26100.0</TargetFrameworks>
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>MSIX</WindowsPackageType>
<!-- <TargetFrameworks>;net9.0-android35.0</TargetFrameworks> --> <!-- <TargetFrameworks>;net9.0-android35.0</TargetFrameworks> -->
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android36.0|AnyCPU'">
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
<ApplicationVersion>11</ApplicationVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.26100.0|AnyCPU'">
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
<ApplicationVersion>11</ApplicationVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android36.0|AnyCPU'">
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
<ApplicationVersion>11</ApplicationVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.26100.0|AnyCPU'">
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
<ApplicationVersion>11</ApplicationVersion>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- App Icon --> <!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4"/> <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen --> <!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#F7931D" BaseSize="128,128"/> <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#F7931D" BaseSize="128,128" />
<!-- Splash Screen (Windows fix) --> <!-- Splash Screen (Windows fix) -->
<!--<MauiImage Include="Resources\Images\logo_splash_win.svg" Color="#F7931D" BaseSize="208,208" />--> <!--<MauiImage Include="Resources\Images\logo_splash_win.svg" Color="#F7931D" BaseSize="208,208" />-->
<!-- Images --> <!-- Images -->
<MauiImage Include="Resources\Images\*"/> <MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185"/> <MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts --> <!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*"/> <MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) --> <!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)"/> <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Remove="Resources\Windows\%24placeholder%24.scale-100.png"/> <None Remove="Resources\Windows\%24placeholder%24.scale-100.png" />
<None Remove="Resources\Windows\%24placeholder%24.scale-125.png"/> <None Remove="Resources\Windows\%24placeholder%24.scale-125.png" />
<None Remove="Resources\Windows\%24placeholder%24.scale-150.png"/> <None Remove="Resources\Windows\%24placeholder%24.scale-150.png" />
<None Remove="Resources\Windows\%24placeholder%24.scale-200.png"/> <None Remove="Resources\Windows\%24placeholder%24.scale-200.png" />
<None Remove="Resources\Windows\%24placeholder%24.scale-400.png"/> <None Remove="Resources\Windows\%24placeholder%24.scale-400.png" />
<None Remove="Resources\Windows\Small\%24placeholder%24.scale-100.png"/> <None Remove="Resources\Windows\Small\%24placeholder%24.scale-100.png" />
<None Remove="Resources\Windows\Small\%24placeholder%24.scale-125.png"/> <None Remove="Resources\Windows\Small\%24placeholder%24.scale-125.png" />
<None Remove="Resources\Windows\Small\%24placeholder%24.scale-150.png"/> <None Remove="Resources\Windows\Small\%24placeholder%24.scale-150.png" />
<None Remove="Resources\Windows\Small\%24placeholder%24.scale-200.png"/> <None Remove="Resources\Windows\Small\%24placeholder%24.scale-200.png" />
<None Remove="Resources\Windows\Small\%24placeholder%24.scale-400.png"/> <None Remove="Resources\Windows\Small\%24placeholder%24.scale-400.png" />
<None Remove="Resources\Windows\Splash\%24placeholder%24.scale-100.png"/> <None Remove="Resources\Windows\Splash\%24placeholder%24.scale-100.png" />
<None Remove="Resources\Windows\Splash\%24placeholder%24.scale-125.png"/> <None Remove="Resources\Windows\Splash\%24placeholder%24.scale-125.png" />
<None Remove="Resources\Windows\Splash\%24placeholder%24.scale-150.png"/> <None Remove="Resources\Windows\Splash\%24placeholder%24.scale-150.png" />
<None Remove="Resources\Windows\Splash\%24placeholder%24.scale-200.png"/> <None Remove="Resources\Windows\Splash\%24placeholder%24.scale-200.png" />
<None Remove="Resources\Windows\Splash\%24placeholder%24.scale-400.png"/> <None Remove="Resources\Windows\Splash\%24placeholder%24.scale-400.png" />
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-100.png"/> <None Remove="Resources\Windows\Wide\%24placeholder%24.scale-100.png" />
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-125.png"/> <None Remove="Resources\Windows\Wide\%24placeholder%24.scale-125.png" />
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-150.png"/> <None Remove="Resources\Windows\Wide\%24placeholder%24.scale-150.png" />
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-200.png"/> <None Remove="Resources\Windows\Wide\%24placeholder%24.scale-200.png" />
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-400.png"/> <None Remove="Resources\Windows\Wide\%24placeholder%24.scale-400.png" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Resources\Windows\$placeholder$.scale-100.png"/> <Content Include="Resources\Windows\$placeholder$.scale-100.png" />
<Content Include="Resources\Windows\$placeholder$.scale-125.png"/> <Content Include="Resources\Windows\$placeholder$.scale-125.png" />
<Content Include="Resources\Windows\$placeholder$.scale-150.png"/> <Content Include="Resources\Windows\$placeholder$.scale-150.png" />
<Content Include="Resources\Windows\$placeholder$.scale-200.png"/> <Content Include="Resources\Windows\$placeholder$.scale-200.png" />
<Content Include="Resources\Windows\$placeholder$.scale-400.png"/> <Content Include="Resources\Windows\$placeholder$.scale-400.png" />
<Content Include="Resources\Windows\Small\$placeholder$.scale-100.png"/> <Content Include="Resources\Windows\Small\$placeholder$.scale-100.png" />
<Content Include="Resources\Windows\Small\$placeholder$.scale-125.png"/> <Content Include="Resources\Windows\Small\$placeholder$.scale-125.png" />
<Content Include="Resources\Windows\Small\$placeholder$.scale-150.png"/> <Content Include="Resources\Windows\Small\$placeholder$.scale-150.png" />
<Content Include="Resources\Windows\Small\$placeholder$.scale-200.png"/> <Content Include="Resources\Windows\Small\$placeholder$.scale-200.png" />
<Content Include="Resources\Windows\Small\$placeholder$.scale-400.png"/> <Content Include="Resources\Windows\Small\$placeholder$.scale-400.png" />
<Content Include="Resources\Windows\Splash\$placeholder$.scale-100.png"/> <Content Include="Resources\Windows\Splash\$placeholder$.scale-100.png" />
<Content Include="Resources\Windows\Splash\$placeholder$.scale-125.png"/> <Content Include="Resources\Windows\Splash\$placeholder$.scale-125.png" />
<Content Include="Resources\Windows\Splash\$placeholder$.scale-150.png"/> <Content Include="Resources\Windows\Splash\$placeholder$.scale-150.png" />
<Content Include="Resources\Windows\Splash\$placeholder$.scale-200.png"/> <Content Include="Resources\Windows\Splash\$placeholder$.scale-200.png" />
<Content Include="Resources\Windows\Splash\$placeholder$.scale-400.png"/> <Content Include="Resources\Windows\Splash\$placeholder$.scale-400.png" />
<Content Include="Resources\Windows\Wide\$placeholder$.scale-100.png"/> <Content Include="Resources\Windows\Wide\$placeholder$.scale-100.png" />
<Content Include="Resources\Windows\Wide\$placeholder$.scale-125.png"/> <Content Include="Resources\Windows\Wide\$placeholder$.scale-125.png" />
<Content Include="Resources\Windows\Wide\$placeholder$.scale-150.png"/> <Content Include="Resources\Windows\Wide\$placeholder$.scale-150.png" />
<Content Include="Resources\Windows\Wide\$placeholder$.scale-200.png"/> <Content Include="Resources\Windows\Wide\$placeholder$.scale-200.png" />
<Content Include="Resources\Windows\Wide\$placeholder$.scale-400.png"/> <Content Include="Resources\Windows\Wide\$placeholder$.scale-400.png" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -256,18 +276,19 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Maui" Version="12.2.0"/> <PackageReference Include="CommunityToolkit.Maui" Version="12.2.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.110"> <PackageReference Include="Microsoft.Maui.Controls" Version="9.0.110">
<TreatAsUsed>true</TreatAsUsed> <TreatAsUsed>true</TreatAsUsed>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.110"/> <PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.110" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9"/> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" />
<PackageReference Include="Microsoft.Maui.Graphics" Version="9.0.110"/> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.NET.Runtime.MonoAOTCompiler.Task" Version="9.0.9"/> <PackageReference Include="Microsoft.Maui.Graphics" Version="9.0.110" />
<PackageReference Include="Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk" Version="9.0.9"/> <PackageReference Include="Microsoft.NET.Runtime.MonoAOTCompiler.Task" Version="9.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> <PackageReference Include="Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk" Version="9.0.9" />
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.5.0"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging;
using ZXing.Net.Maui.Controls; using ZXing.Net.Maui.Controls;
using System.Net.Http; using System.Net.Http;
using Jugenddienst_Stunden.ViewModels; using Jugenddienst_Stunden.ViewModels;
using Jugenddienst_Stunden.Views;
using System.Net;
namespace Jugenddienst_Stunden; namespace Jugenddienst_Stunden;
@@ -26,85 +28,52 @@ public static class MauiProgram {
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
}) })
//.UseBarcodeScanning();
.UseBarcodeReader(); .UseBarcodeReader();
//#if DEBUG
// if (string.IsNullOrWhiteSpace(GlobalVar.ApiKey)) {
// GlobalVar.ApiKey = Preferences.Default.Get("apiKey",
// "MTQxfHNkdFptQkNZTXlPT3ZyMHNBZDl0UnVxNExMRXxodHRwOi8vaG91cnMuZGF1bmkubWluZS5udTo4MS9hcHBhcGk=");
// GlobalVar.Name = Preferences.Default.Get("name", "Testserver: Isabell");
// GlobalVar.Surname = Preferences.Default.Get("surname", "Biasi");
// GlobalVar.EmployeeId = Preferences.Default.Get("EmployeeId", 141);
// GlobalVar.ApiUrl = Preferences.Default.Get("apiUrl", "https://hours.dauni.mine.nu/appapi");
// }
// builder.Logging.AddDebug();
//#endif
// DI: AlertService für globale Alerts (z. B. leere ApiUrl) // DI: AlertService für globale Alerts (z. B. leere ApiUrl)
builder.Services.AddSingleton<IAlertService, AlertService>(); builder.Services.AddSingleton<IAlertService, AlertService>();
// DI: Settings aus Preferences (Single Source of Truth bleibt Preferences) // DI: Settings aus Preferences (Single Source of Truth bleibt Preferences)
builder.Services.AddSingleton<IAppSettings, PreferencesAppSettings>(); builder.Services.AddSingleton<IAppSettings, AppSettings>();
// DI: ApiOptions IMMER aus aktuellen Settings erzeugen (nicht beim Start einfrieren) // DI: ApiOptions IMMER aus aktuellen Settings erzeugen (nicht beim Start einfrieren)
builder.Services.AddTransient(sp => new ApiOptions { builder.Services.AddTransient(sp => new ApiOptions {
BaseUrl = sp.GetRequiredService<IAppSettings>().ApiUrl, Timeout = TimeSpan.FromSeconds(15) BaseUrl = sp.GetRequiredService<IAppSettings>().ApiUrl,
Timeout = TimeSpan.FromSeconds(15)
}); });
// Token Provider soll ebenfalls aus Settings/Preferences lesen
builder.Services.AddSingleton<ITokenProvider, SettingsTokenProvider>();
// HttpClient + ApiClient // HttpClient + ApiClient Best Practices:
// Configure HttpClient with RequestLoggingHandler and disable automatic redirects for diagnosis // 1. IHttpClientFactory verwenden (vermeidet Socket Exhaustion & DNS Probleme)
// 2. Typed Client für bessere Dependency Injection (AddHttpClient<TInterface, TImplementation>)
// 3. DelegatingHandler für Logging/Infrastruktur einbinden
builder.Services.AddTransient<RequestLoggingHandler>(); builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services.AddSingleton<HttpClient>(sp => {
var nativeHandler = new HttpClientHandler { AllowAutoRedirect = false };
var logging = sp.GetRequiredService<RequestLoggingHandler>();
logging.InnerHandler = nativeHandler;
// HttpClient.Timeout will be adjusted by ApiClient if needed
return new HttpClient(logging, disposeHandler: true);
});
builder.Services.AddSingleton<IApiClient>(sp => { builder.Services.AddHttpClient<IApiClient, ApiClient>()
var alert = sp.GetRequiredService<IAlertService>(); .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler {
try { AllowAutoRedirect = false,
return new ApiClient( AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
sp.GetRequiredService<HttpClient>(), PooledConnectionLifetime = TimeSpan.FromMinutes(5),
sp.GetRequiredService<ApiOptions>(), ConnectTimeout = TimeSpan.FromSeconds(10)
sp.GetRequiredService<ITokenProvider>(), })
sp.GetRequiredService<IAppSettings>()); .AddHttpMessageHandler<RequestLoggingHandler>()
} catch (Exception e) { .SetHandlerLifetime(TimeSpan.FromMinutes(5));
// Alert an UI/VM weiterreichen
alert.Raise(e.Message);
// Fallback: NullApiClient liefert beim Aufruf aussagekräftige Exception
return new NullApiClient(e.Message);
}
});
// DI: Infrastruktur
//builder.Services.AddSingleton(new ApiOptions { BaseUrl = GlobalVar.ApiUrl, Timeout = TimeSpan.FromSeconds(15) });
//builder.Services.AddSingleton<ITokenProvider, GlobalVarTokenProvider>();
//builder.Services.AddSingleton<HttpClient>(_ => new HttpClient());
//builder.Services.AddSingleton<IApiClient>(sp => new ApiClient(
// sp.GetRequiredService<HttpClient>(),
// sp.GetRequiredService<ApiOptions>(),
// sp.GetRequiredService<ITokenProvider>()));
// DI: Validatoren // DI: Validatoren
builder.Services.AddSingleton<IHoursValidator, HoursValidator>(); builder.Services.AddSingleton<IHoursValidator, HoursValidator>();
// DI: Services & Repositories // DI: Services & Repositories
builder.Services.AddSingleton<IHoursRepository, HoursRepository>(); builder.Services.AddSingleton<IHoursRepository, HoursRepository>();
builder.Services.AddSingleton<IHoursService, HoursService>(); builder.Services.AddSingleton<IHoursService, HoursService>();
builder.Services.AddSingleton<IAuthService, AuthService>(); builder.Services.AddSingleton<IAuthService, AuthService>();
// DI: Views/ViewModels // DI: Views/ViewModels
builder.Services.AddTransient<ViewModels.StundenViewModel>(); builder.Services.AddTransient<StundenViewModel>();
builder.Services.AddTransient<Views.StundenPage>(); builder.Services.AddTransient<StundenPage>();
builder.Services.AddTransient<ViewModels.LoginViewModel>(); builder.Services.AddTransient<StundeViewModel>();
builder.Services.AddTransient<Views.LoginPage>(); builder.Services.AddTransient<StundePage>();
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<LoginPage>();
return builder.Build(); return builder.Build();
} }

View File

@@ -100,82 +100,7 @@ internal static class BaseFunc {
new() { Date = File.GetLastWriteTime(filename) }; new() { Date = File.GetLastWriteTime(filename) };
} }
/// <summary>
/// Stundeneintrag speichern
/// </summary>
internal static async Task SaveItemAsync(string url, string token, DayTime item, bool isNewItem = false) {
//Uhrzeiten sollten sinnvolle Werte haben - außer bei Freistellungen, da wäre eigentlich null
if (item.TimeSpanVon == item.TimeSpanBis && item.FreistellungAktiv == null) {
throw new Exception("Beginn und Ende sind gleich");
}
if (item.TimeSpanBis < item.TimeSpanVon) {
throw new Exception("Ende ist vor Beginn");
}
TimeSpan span = TimeSpan.Zero;
span += item.TimeSpanBis - item.TimeSpanVon;
if (span.Hours > 10) {
//Hier vielleicht eine Abfrage, ob mehr als 10 Stunden gesund sind?
//Das müsste aber das ViewModel machen
}
//Gemeinde ist ein Pflichtfeld
if (item.GemeindeAktiv == null && GlobalVar.Settings.GemeindeAktivSet) {
throw new Exception("Gemeinde nicht gewählt");
}
//Projekt ist ein Pflichtfeld
if (item.ProjektAktiv == null && GlobalVar.Settings.ProjektAktivSet) {
throw new Exception("Projekt nicht gewählt");
}
//Keine Beschreibung
if (string.IsNullOrEmpty(item.Description) && item.FreistellungAktiv == null) {
throw new Exception("Keine Beschreibung");
}
//Keine Beschreibung
if (string.IsNullOrEmpty(item.Description)) {
item.Description = item.FreistellungAktiv.Name;
}
using (HttpClient client = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) }) {
//HttpClient client = new HttpClient();
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
//string json = JsonSerializer.Serialize<DayTime>(item);
string json = JsonConvert.SerializeObject(item);
StringContent content = new StringContent(json, Encoding.UTF8, "application/json");
HttpResponseMessage? response = null;
if (isNewItem)
response = await client.PostAsync(url, content);
else
response = await client.PutAsync(url, content);
if (!response.IsSuccessStatusCode) {
throw new Exception("Fehler beim Speichern " + response.Content);
}
}
}
/// <summary>
/// Stundeneintrag löschen
/// </summary>
internal static async Task DeleteItemAsync(string url, string token) {
using (HttpClient client = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) }) {
//HttpClient client = new HttpClient();
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage response = await client.DeleteAsync(url);
if (!response.IsSuccessStatusCode)
throw new Exception("Fehler beim Löschen " + response.Content);
}
}
} }

View File

@@ -1,32 +0,0 @@
using Jugenddienst_Stunden.Types;
namespace Jugenddienst_Stunden.Models;
internal static class GlobalVar {
public static string ApiKey {
get => Preferences.Default.Get("apiKey", "");
set => Preferences.Default.Set("apiKey", value);
}
public static int EmployeeId {
get => Preferences.Default.Get("EmployeeId", 0);
set => Preferences.Default.Set("EmployeeId", value);
}
public static string Name {
get => Preferences.Default.Get("name", "Nicht");
set => Preferences.Default.Set("name", value);
}
public static string Surname {
get => Preferences.Default.Get("surname", "Eingeloggt");
set => Preferences.Default.Set("surname", value);
}
public static string ApiUrl {
get => Preferences.Default.Get("apiUrl", "");
set => Preferences.Default.Set("apiUrl", value);
}
public static Settings Settings { get; set; }
}

View File

@@ -1,93 +0,0 @@
using Jugenddienst_Stunden.Types;
using Newtonsoft.Json;
namespace Jugenddienst_Stunden.Models;
internal static class HoursBase {
/// <summary>
/// Laden ... what can be: "settings", "hours", date="YYYY-MM-DD", id=<int/>
/// </summary>
/// <returns>Entire response</returns>
internal static async Task<BaseResponse> LoadBase(string what) {
string data = await BaseFunc.GetApiDataWithAuthAsync(GlobalVar.ApiUrl + "?" + what, GlobalVar.ApiKey);
BaseResponse res = JsonConvert.DeserializeObject<BaseResponse>(data) ??
throw new Exception("Fehler beim Deserialisieren der Daten");
return res;
}
/// <summary>
/// Einstellungen laden
/// </summary>
/// <returns>Settings only</returns>
internal static async Task<Settings> LoadSettings() {
string data = await BaseFunc.GetApiDataWithAuthAsync(GlobalVar.ApiUrl + "?settings", GlobalVar.ApiKey);
BaseResponse res = JsonConvert.DeserializeObject<BaseResponse>(data) ??
throw new Exception("Fehler beim Deserialisieren der Daten");
return res.settings;
}
/// <summary>
/// Daten laden
/// </summary>
/// <returns>Hours only</returns>
internal static async Task<Hours> LoadData() {
string data = await BaseFunc.GetApiDataWithAuthAsync(GlobalVar.ApiUrl + "?hours", GlobalVar.ApiKey);
BaseResponse res = JsonConvert.DeserializeObject<BaseResponse>(data) ??
throw new Exception("Fehler beim Deserialisieren der Daten");
return res.hour;
}
/// <summary>
/// Benutzerdaten laden
/// </summary>
/// <returns>User-Object</returns>
public static async Task<User> LoadUser(string apiKey) {
string data = await BaseFunc.GetApiDataWithAuthAsync(GlobalVar.ApiUrl, apiKey);
BaseResponse res = JsonConvert.DeserializeObject<BaseResponse>(data) ??
throw new Exception("Fehler beim Deserialisieren der Daten");
return res.user;
}
/// <summary>
/// Zeiten eines Tages holen
/// </summary>
internal static async Task<List<DayTime>> LoadDay(DateTime date) {
string data = await BaseFunc.GetApiDataWithAuthAsync(GlobalVar.ApiUrl + "?date=" + date.ToString("yyyy-MM-dd"),
GlobalVar.ApiKey);
BaseResponse res = JsonConvert.DeserializeObject<BaseResponse>(data) ??
throw new Exception("Fehler beim Deserialisieren der Daten");
return res.daytimes;
}
/// <summary>
/// Einzelnen Stundeneintrag holen
/// </summary>
internal static async Task<DayTime> LoadEntry(int id) {
string data = await BaseFunc.GetApiDataWithAuthAsync(GlobalVar.ApiUrl + "?id=" + id, GlobalVar.ApiKey);
BaseResponse res = JsonConvert.DeserializeObject<BaseResponse>(data) ??
throw new Exception("Fehler beim Deserialisieren der Daten");
res.daytime.TimeSpanVon = res.daytime.Begin.ToTimeSpan();
res.daytime.TimeSpanBis = res.daytime.End.ToTimeSpan();
return res.daytime;
}
/// <summary>
/// Eintrag speichern
/// </summary>
internal static async Task<DayTime> SaveEntry(DayTime stunde) {
//, string begin, string end, string freistellung, string bemerkung) {
bool isNew = false;
if (stunde.Id == null)
isNew = true;
await BaseFunc.SaveItemAsync(GlobalVar.ApiUrl, GlobalVar.ApiKey, stunde, isNew);
return stunde;
}
/// <summary>
/// Eintrag löschen
/// </summary>
internal static async Task DeleteEntry(DayTime stunde) {
await BaseFunc.DeleteItemAsync(GlobalVar.ApiUrl + "/entry/" + stunde.Id, GlobalVar.ApiKey);
}
}

View File

@@ -1,7 +1,7 @@
{ {
"profiles": { "profiles": {
"Windows Machine": { "Windows Machine": {
"commandName": "Project", "commandName": "MsixPackage",
"nativeDebugging": false "nativeDebugging": false
} }
} }

View File

@@ -62,7 +62,7 @@ internal class HoursRepository : IHoursRepository {
} }
public Task DeleteEntry(DayTime stunde) public Task DeleteEntry(DayTime stunde)
=> _api.DeleteAsync($"/entry/{stunde.Id}"); => _api.DeleteAsync($"entry/{stunde.Id}");
private static Dictionary<string, string?> QueryToDictionary(string query) { private static Dictionary<string, string?> QueryToDictionary(string query) {
var dict = new Dictionary<string, string?>(); var dict = new Dictionary<string, string?>();

View File

@@ -48,6 +48,15 @@ internal class HoursService : IHoursService {
public async Task<DayTime> GetEntryAsync(int id) => await _repo.LoadEntry(id); public async Task<DayTime> GetEntryAsync(int id) => await _repo.LoadEntry(id);
public async Task<(DayTime dayTime, Settings settings, List<DayTime> existingDayTimes)> GetEntryWithSettingsAsync(int id) {
//var stunde = await _repo.LoadEntry(id);
//var (existingDayTimes, settings) = await GetDayWithSettingsAsync(stunde.Day);
//return (stunde, settings, existingDayTimes);
string q = $"id={id}";
var baseRes = await _repo.LoadBase(q);
return (baseRes.daytime ?? new DayTime(), baseRes.settings, baseRes.daytimes ?? new List<DayTime>());
}
public async Task<DayTime> SaveEntryAsync(DayTime stunde) { public async Task<DayTime> SaveEntryAsync(DayTime stunde) {
var settings = await _repo.LoadSettings(); var settings = await _repo.LoadSettings();
_validator.Validate(stunde, settings); _validator.Validate(stunde, settings);

View File

@@ -1,4 +1,5 @@
using Jugenddienst_Stunden.Models; using Jugenddienst_Stunden.Models;
using Jugenddienst_Stunden.Types;
namespace Jugenddienst_Stunden.Types; namespace Jugenddienst_Stunden.Types;
@@ -8,12 +9,12 @@ internal class BaseResponse {
/// <summary> /// <summary>
/// Monatsübersicht /// Monatsübersicht
/// </summary> /// </summary>
public Hours hour { get; set; } public Types.Hours hour { get; set; }
/// <summary> /// <summary>
/// Stundenliste ... für die Katz? /// Stundenliste ... für die Katz?
/// </summary> /// </summary>
public List<Hours> hours { get; set; } public List<Types.Hours> hours { get; set; }
/// <summary> /// <summary>
/// Liste der Stundeneinträge /// Liste der Stundeneinträge

View File

@@ -4,24 +4,45 @@ using System.Collections.ObjectModel;
namespace Jugenddienst_Stunden.Types; namespace Jugenddienst_Stunden.Types;
public partial class Hours : ObservableObject { public partial class Hours : ObservableObject {
public double? Zeit; /// <summary>
/// Total time in seconds for the current context.
/// "zeit" is used by the API to represent the current recorded time value.
/// </summary>
public int? zeit { get; set; }
public double? Nominal; /// <summary>
/// Nominal working time expectation (e.g. seconds per day or month depending on API semantics).
/// Represents the expected amount of time to be worked.
/// </summary>
public int? nominal { get; set; }
//public Dictionary<DateOnly,NominalDay> nominal_day_api; /// <summary>
/// List of nominal day records returned by the API.
/// May be null when the API does not provide per-day nominal data.
/// </summary>
public List<NominalDay>? Nominal_day_api; public List<NominalDay>? Nominal_day_api;
//public Dictionary<int,NominalWeek> nominal_week_api; /// <summary>
/// List of nominal week records returned by the API.
/// May be null when the API does not provide per-week nominal data.
/// </summary>
public List<NominalWeek>? Nominal_week_api; public List<NominalWeek>? Nominal_week_api;
//public List<string> time_line; /// <summary>
public double? Zeit_total; /// Total time in seconds reported by the API for the current period. Nullable if not provided.
/// </summary>
//https://stackoverflow.com/questions/29449641/deserialize-json-when-a-value-can-be-an-object-or-an-empty-array/29450279#29450279 public double? zeit_total { get; set; }
//[JsonConverter(typeof(JsonSingleOrEmptyArrayConverter<Hours>))]
//public Dictionary<int,decimal> zeit_total_daily;
/// <summary>
/// Daily total time values returned by the API.
/// Each entry represents a day with its associated time value.
/// </summary>
public List<TimeDay> zeit_total_daily_api; public List<TimeDay> zeit_total_daily_api;
/// <summary>
/// Collection of daytime entries representing individual recorded time slots or events.
/// Nullable when the API returns no detailed daytime information.
/// </summary>
public List<DayTime>? daytime; public List<DayTime>? daytime;
//public List<string> wochensumme; //public List<string> wochensumme;
@@ -33,7 +54,7 @@ public partial class Hours : ObservableObject {
[ObservableProperty] public double zeitausgleich; [ObservableProperty] public double zeitausgleich;
public double zeitausgleich_month; public double zeitausgleich_month;
public double holiday; public double? holiday { get; set; }
public double krankheit; public double krankheit;
public double weiterbildung; public double weiterbildung;
public double bereitschaft; public double bereitschaft;

View File

@@ -10,8 +10,8 @@ namespace Jugenddienst_Stunden.ViewModels;
/// </summary> /// </summary>
public partial class LoginViewModel : ObservableObject { public partial class LoginViewModel : ObservableObject {
private readonly IAuthService _auth; private readonly IAuthService _auth;
private readonly IAppSettings _settings;
private readonly IAlertService? _alerts; private readonly IAlertService? _alerts;
private readonly IAppSettings _settings;
private DateTime _lastDetectionTime = DateTime.MinValue; private DateTime _lastDetectionTime = DateTime.MinValue;
private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5); private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5);
@@ -39,7 +39,7 @@ public partial class LoginViewModel : ObservableObject {
private string? serverLabel; private string? serverLabel;
[ObservableProperty] [ObservableProperty]
private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt"); private string title = "Nicht eingeloggt";
[ObservableProperty] [ObservableProperty]
private string? username; private string? username;
@@ -70,22 +70,36 @@ public partial class LoginViewModel : ObservableObject {
IsDetecting = !isManualMode; IsDetecting = !isManualMode;
// Serveranzeige vorbereiten // Serveranzeige vorbereiten
var apiUrl = Preferences.Default.Get("apiUrl", string.Empty); RefreshSettings();
if (!string.IsNullOrWhiteSpace(apiUrl)) {
Server = apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", "");
ServerLabel = "Server: " + Server;
}
// Command initialisieren // Command initialisieren
QrDetectedCommand = new AsyncRelayCommand<object?>(QrDetectedAsync); QrDetectedCommand = new AsyncRelayCommand<object?>(QrDetectedAsync);
} }
// DI-Konstruktor: AlertService anbinden und Alerts an VM-Event weiterreichen (analog StundeViewModel) // DI-Konstruktor: AlertService anbinden und Alerts an VM-Event weiterreichen (analog StundeViewModel)
internal LoginViewModel(IAuthService auth, IAppSettings settings, IAlertService alertService) : this(auth, settings) { internal LoginViewModel(IAuthService auth, IAlertService alertService,IAppSettings settings) : this(auth,settings) {
_alerts = alertService; _alerts = alertService;
_settings = settings;
if (alertService is not null) { if (alertService is not null) {
alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg); alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg);
} }
RefreshSettings();
}
/// <summary>
/// Aktualisiert die Serveranzeige aus den aktuellen AppSettings.
/// </summary>
public void RefreshSettings() {
var apiUrl = _settings.ApiUrl;
if (!string.IsNullOrWhiteSpace(apiUrl)) {
Server = apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", "");
ServerLabel = "Server: " + Server;
} else {
Server = string.Empty;
ServerLabel = "Server: Nicht konfiguriert";
}
Title = $"{_settings.Name} {_settings.Surname}";
} }
partial void OnIsManualModeChanged(bool value) { partial void OnIsManualModeChanged(bool value) {

View File

@@ -19,13 +19,13 @@ namespace Jugenddienst_Stunden.ViewModels;
/// </summary> /// </summary>
public partial class StundeViewModel : ObservableObject, IQueryAttributable { public partial class StundeViewModel : ObservableObject, IQueryAttributable {
private readonly IHoursService _hoursService; private readonly IHoursService _hoursService;
private readonly IAppSettings _settings;
private readonly IAlertService _alertService;
public int Id { get; set; } public int Id { get; set; }
public string Title { get; set; } = "Eintrag bearbeiten"; public string Title { get; set; } = "Eintrag bearbeiten";
public string SubTitle { get; set; } = DateTime.Today.ToString("dddd, d. MMMM yyyy"); public string SubTitle { get; set; } = DateTime.Today.ToString("dddd, d. MMMM yyyy");
//private HoursBase HoursBase = new HoursBase();
internal Settings Settings = new Settings();
public event EventHandler<string> AlertEvent; public event EventHandler<string> AlertEvent;
public event EventHandler<string> InfoEvent; public event EventHandler<string> InfoEvent;
@@ -74,52 +74,30 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
public ICommand SaveCommand { get; private set; } public ICommand SaveCommand { get; private set; }
public ICommand DeleteCommand { get; private set; } public ICommand DeleteCommand { get; private set; }
public ICommand DeleteConfirmCommand { get; private set; } public ICommand DeleteConfirmCommand { get; private set; }
public ICommand SelectEntryCommand { get; }
//public ICommand LoadDataCommand { get; private set; } //public ICommand LoadDataCommand { get; private set; }
public StundeViewModel() : this(GetServiceOrCreate()) { public StundeViewModel(IHoursService hoursService, IAlertService alertService, IAppSettings settings) {
LoadSettingsAsync();
}
private static IHoursService GetServiceOrCreate() {
// Fallback-Konstruktion, falls DI nicht injiziert wurde (z. B. im Designer)
var http = new HttpClient();
var options = new Infrastructure.ApiOptions { BaseUrl = GlobalVar.ApiUrl, Timeout = TimeSpan.FromSeconds(15) };
var tokenProvider = new GlobalVarTokenProvider();
var api = new ApiClient(http, options, tokenProvider, new PreferencesAppSettings());
var repo = new HoursRepository(api);
var validator = new HoursValidator();
return new HoursService(repo, validator);
}
internal StundeViewModel(IHoursService hoursService) {
_hoursService = hoursService; _hoursService = hoursService;
_settings = settings;
_alertService = alertService;
SaveCommand = new AsyncRelayCommand(Save); SaveCommand = new AsyncRelayCommand(Save);
//DeleteCommand = new AsyncRelayCommand(Delete);
DeleteConfirmCommand = new Command(async () => await DeleteConfirm()); DeleteConfirmCommand = new Command(async () => await DeleteConfirm());
SelectEntryCommand = new AsyncRelayCommand<DayTime>(SelectEntryAsync);
_alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg);
} }
// DI-Konstruktor, der den globalen Alert-Service abonniert und Alerts an das ViewModel weiterreicht.
internal StundeViewModel(IHoursService hoursService, IAlertService alertService) : this(hoursService) {
if (alertService is not null) {
alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg);
}
}
private async void LoadSettingsAsync() { private void UpdateSettings(Settings settings) {
try { _settings.Settings = settings;
Settings = await _hoursService.GetSettingsAsync(); OptionsGemeinde = settings.Gemeinden;
GlobalVar.Settings = Settings; OptionsProjekt = settings.Projekte;
OptionsFreistellung = settings.Freistellungen;
OptionsGemeinde = Settings.Gemeinden; GemeindeAktivSet = settings.GemeindeAktivSet;
OptionsProjekt = Settings.Projekte; ProjektAktivSet = settings.ProjektAktivSet;
OptionsFreistellung = Settings.Freistellungen;
GemeindeAktivSet = Settings.GemeindeAktivSet;
ProjektAktivSet = Settings.ProjektAktivSet;
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
}
} }
async Task Save() { async Task Save() {
@@ -133,15 +111,21 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
} }
//Projekt ist ein Pflichtfeld //Projekt ist ein Pflichtfeld
if (Settings.ProjektAktivSet && DayTime.ProjektAktiv.Id == 0) { if (_settings.Settings.ProjektAktivSet) {
proceed = false; var projektId = DayTime.ProjektAktiv?.Id ?? 0;
AlertEvent?.Invoke(this, "Projekt darf nicht leer sein"); if (projektId == 0) {
proceed = false;
AlertEvent?.Invoke(this, "Projekt darf nicht leer sein");
}
} }
//Gemeinde ist ein Pflichtfeld //Gemeinde ist ein Pflichtfeld
if (Settings.GemeindeAktivSet && DayTime.GemeindeAktiv.Id == 0) { if (_settings.Settings.GemeindeAktivSet) {
proceed = false; var gemeindeId = DayTime.GemeindeAktiv?.Id ?? 0;
AlertEvent?.Invoke(this, "Gemeinde darf nicht leer sein"); if (gemeindeId == 0) {
proceed = false;
AlertEvent?.Invoke(this, "Gemeinde darf nicht leer sein");
}
} }
if (proceed) { if (proceed) {
@@ -154,7 +138,8 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
if (!exceptionOccurred) { if (!exceptionOccurred) {
if (DayTime.Id != null) { if (DayTime.Id != null) {
await Shell.Current.GoToAsync($"..?saved={DayTime.Id}"); //await Shell.Current.GoToAsync($"..?saved={DayTime.Id}");
await Shell.Current.GoToAsync($"//StundenPage?saved={DayTime.Id}");
} else { } else {
await Shell.Current.GoToAsync($"..?date={DayTime.Day.ToString("yyyy-MM-dd")}"); await Shell.Current.GoToAsync($"..?date={DayTime.Day.ToString("yyyy-MM-dd")}");
} }
@@ -179,14 +164,31 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
await ConfirmEvent.Invoke("Achtung", "Löschen kann nicht ungeschehen gemacht werden. Fortfahren?"); await ConfirmEvent.Invoke("Achtung", "Löschen kann nicht ungeschehen gemacht werden. Fortfahren?");
if (answer) { if (answer) {
//Löschen //Löschen
await _hoursService.DeleteEntryAsync(DayTime); try {
await Shell.Current.GoToAsync($"..?date={DayTime.Day.ToString("yyyy-MM-dd")}"); await _hoursService.DeleteEntryAsync(DayTime);
await Shell.Current.GoToAsync($"..?date={DayTime.Day.ToString("yyyy-MM-dd")}");
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
}
} else { } else {
//nicht Löschen //nicht Löschen
} }
} }
} }
/// <summary>
/// Öffnet eine bestehende Stundeneingabe
/// </summary>
private async Task SelectEntryAsync(DayTime entry) {
if (entry != null && entry.Id != null) {
//var navigationParameters = new Dictionary<string, object> { { "load", entry.id } };
//await Shell.Current.GoToAsync($"{nameof(Views.StundePage)}", navigationParameters);
await Shell.Current.GoToAsync($"{nameof(Views.StundePage)}?load={entry.Id}");
} else AlertEvent?.Invoke(this, "Auswahl enthält keine Daten");
}
/// <summary> /// <summary>
/// Anwenden der Query-Parameter /// Anwenden der Query-Parameter
/// </summary> /// </summary>
@@ -196,20 +198,15 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
if (query.ContainsKey("load")) { if (query.ContainsKey("load")) {
//DateTime heute = DateTime.Now; //DateTime heute = DateTime.Now;
try { try {
var entry = await _hoursService.GetEntryAsync(Convert.ToInt32(query["load"])); //var entry = await _hoursService.GetEntryAsync(Convert.ToInt32(query["load"]));
// var settings = await _hoursService.GetSettingsAsync(); var (entry, settings, daytimes) =
// GlobalVar.Settings = settings; await _hoursService.GetEntryWithSettingsAsync(Convert.ToInt32(query["load"]));
// GemeindeAktivSet = settings.GemeindeAktivSet; UpdateSettings(settings);
// ProjektAktivSet = settings.ProjektAktivSet;
DayTime = entry; DayTime = entry;
DayTime.TimeSpanVon = entry.Begin.ToTimeSpan(); DayTime.TimeSpanVon = entry.Begin.ToTimeSpan();
DayTime.TimeSpanBis = entry.End.ToTimeSpan(); DayTime.TimeSpanBis = entry.End.ToTimeSpan();
// OptionsGemeinde = settings.Gemeinden ?? new List<Gemeinde>();
// OptionsProjekt = settings.Projekte ?? new List<Projekt>();
// OptionsFreistellung = settings.Freistellungen ?? new List<Freistellung>();
DayTime.GemeindeAktiv = OptionsGemeinde.FirstOrDefault(Gemeinde => Gemeinde.Id == DayTime.Gemeinde) ?? DayTime.GemeindeAktiv = OptionsGemeinde.FirstOrDefault(Gemeinde => Gemeinde.Id == DayTime.Gemeinde) ??
new Gemeinde(); new Gemeinde();
DayTime.ProjektAktiv = OptionsProjekt.FirstOrDefault(Projekt => Projekt.Id == DayTime.Projekt) ?? DayTime.ProjektAktiv = OptionsProjekt.FirstOrDefault(Projekt => Projekt.Id == DayTime.Projekt) ??
@@ -218,24 +215,22 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
OptionsFreistellung.FirstOrDefault(Freistellung => Freistellung.Id == DayTime.Free) ?? OptionsFreistellung.FirstOrDefault(Freistellung => Freistellung.Id == DayTime.Free) ??
new Freistellung(); new Freistellung();
//Evtl. noch die anderen Zeiten des gleichen Tages holen
var day = await _hoursService.GetDayWithSettingsAsync(DayTime.Day);
DayTimes = day.dayTimes;
OnPropertyChanged(nameof(DayTime)); OnPropertyChanged(nameof(DayTime));
if (System.String.IsNullOrEmpty(DayTime.Description)) {
InfoEvent?.Invoke(this, "Eintrag hat keinen Beschreibungstext");
}
SubTitle = DayTime.Day.ToString("dddd, d. MMMM yyyy");
OnPropertyChanged(nameof(SubTitle));
FreistellungEnabled = !DayTime.Approved;
DayTimes = daytimes;
OnPropertyChanged(nameof(DayTimes)); OnPropertyChanged(nameof(DayTimes));
} catch (Exception e) { } catch (Exception e) {
AlertEvent?.Invoke(this, e.Message); AlertEvent?.Invoke(this, e.Message);
} finally {
} }
if (System.String.IsNullOrEmpty(DayTime.Description)) {
InfoEvent?.Invoke(this, "Eintrag hat keinen Beschreibungstext");
}
SubTitle = DayTime.Day.ToString("dddd, d. MMMM yyyy");
OnPropertyChanged(nameof(SubTitle));
FreistellungEnabled = !DayTime.Approved;
//OnPropertyChanged(nameof(DayTime)); //OnPropertyChanged(nameof(DayTime));
} else if (query.ContainsKey("date")) { } else if (query.ContainsKey("date")) {
Title = "Neuer Eintrag"; Title = "Neuer Eintrag";
@@ -246,24 +241,20 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
//Bei neuem Eintrag die vorhandenen des gleichen Tages anzeigen //Bei neuem Eintrag die vorhandenen des gleichen Tages anzeigen
try { try {
var (list, settings) = await _hoursService.GetDayWithSettingsAsync(_date); var (list, settings) = await _hoursService.GetDayWithSettingsAsync(_date);
GlobalVar.Settings = settings; UpdateSettings(settings);
DayTimes = list; DayTimes = list;
OnPropertyChanged(nameof(DayTimes));
OptionsGemeinde = settings.Gemeinden;
OptionsProjekt = settings.Projekte;
OptionsFreistellung = settings.Freistellungen;
GemeindeAktivSet = settings.GemeindeAktivSet;
ProjektAktivSet = settings.ProjektAktivSet;
} catch (Exception) { } catch (Exception) {
//Ein Tag ohne Einträge gibt eine Fehlermeldung, //Ein Tag ohne Einträge gibt eine Fehlermeldung,
//die soll aber ignoriert werden, weil beim Neueintrag ist das ja Wurscht //die soll aber ignoriert werden, weil beim Neueintrag ist das ja Wurscht
//In dem Fall müssen die Settings aber nochmal geholt werden, weil die dann nicht geladen wurden //In dem Fall müssen die Settings aber nochmal geholt werden, weil die dann nicht geladen wurden
// LoadSettingsAsync(); // LoadSettingsAsync();
var settings = await _hoursService.GetSettingsAsync();
UpdateSettings(settings);
} finally { } finally {
DayTime = new DayTime(); DayTime = new DayTime();
DayTime.Day = _date; DayTime.Day = _date;
DayTime.EmployeeId = GlobalVar.EmployeeId; DayTime.EmployeeId = _settings.EmployeeId;
DayTime.GemeindeAktiv = new Gemeinde(); DayTime.GemeindeAktiv = new Gemeinde();
DayTime.ProjektAktiv = new Projekt(); DayTime.ProjektAktiv = new Projekt();
DayTime.FreistellungAktiv = new Freistellung(); DayTime.FreistellungAktiv = new Freistellung();

View File

@@ -15,6 +15,7 @@ namespace Jugenddienst_Stunden.ViewModels;
/// </summary> /// </summary>
public partial class StundenViewModel : ObservableObject, IQueryAttributable, INotifyPropertyChanged { public partial class StundenViewModel : ObservableObject, IQueryAttributable, INotifyPropertyChanged {
private readonly IHoursService _hoursService; private readonly IHoursService _hoursService;
private readonly IAppSettings _settings;
public ICommand NewEntryCommand { get; } public ICommand NewEntryCommand { get; }
public ICommand SelectEntryCommand { get; } public ICommand SelectEntryCommand { get; }
@@ -50,7 +51,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// </summary> /// </summary>
[ObservableProperty] private List<DayTime> dayTimes = new List<DayTime>(); [ObservableProperty] private List<DayTime> dayTimes = new List<DayTime>();
public string Title { get; set; } = GlobalVar.Name + " " + GlobalVar.Surname; public string Title => _settings.Name + " " + _settings.Surname;
[ObservableProperty] private Hours hours; [ObservableProperty] private Hours hours;
@@ -82,14 +83,10 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
LoadOverview = "Lade Summen für " + dateToday.ToString("MMMM yy"); LoadOverview = "Lade Summen für " + dateToday.ToString("MMMM yy");
// Task.Run(() => LoadDay(value)); // Task.Run(() => LoadDay(value));
// NICHT Task.Run: LoadDay aktualisiert UI-gebundene Properties // NICHT Task.Run: LoadDay aktualisiert UI-gebundene Properties
MainThread.BeginInvokeOnMainThread(async () => MainThread.BeginInvokeOnMainThread(async () => {
{ try {
try
{
await LoadDay(dateToday); await LoadDay(dateToday);
} } catch (Exception ex) {
catch (Exception ex)
{
AlertEvent?.Invoke(this, ex.Message); AlertEvent?.Invoke(this, ex.Message);
} }
}); });
@@ -101,14 +98,14 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// Monatsübersicht: Geleistete Stunden /// Monatsübersicht: Geleistete Stunden
/// </summary> /// </summary>
public double? ZeitCalculated { public double? ZeitCalculated {
get => Hours.Zeit_total; get => Hours.zeit_total;
} }
/// <summary> /// <summary>
/// Monatsübersicht: Sollstunden /// Monatsübersicht: Sollstunden
/// </summary> /// </summary>
public double? Nominal { public double? Nominal {
get => Hours.Nominal; get => Hours.nominal;
} }
/// <summary> /// <summary>
@@ -136,7 +133,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary> /// <summary>
/// Monatsübersicht: Resturlaub /// Monatsübersicht: Resturlaub
/// </summary> /// </summary>
public double Holiday { public double? Holiday {
get => Hours.holiday; get => Hours.holiday;
} }
@@ -162,8 +159,9 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary> /// <summary>
/// CTOR (DI) /// CTOR (DI)
/// </summary> /// </summary>
public StundenViewModel(IHoursService hoursService) { public StundenViewModel(IHoursService hoursService, IAppSettings appSettings) {
_hoursService = hoursService; _hoursService = hoursService;
_settings = appSettings;
Hours = new Hours(); Hours = new Hours();
LoadOverview = "Lade Summen für " + DateToday.ToString("MMMM"); LoadOverview = "Lade Summen für " + DateToday.ToString("MMMM");
@@ -174,20 +172,6 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
RefreshListCommand = new AsyncRelayCommand(RefreshList); RefreshListCommand = new AsyncRelayCommand(RefreshList);
RefreshCommand = new Command(async () => await RefreshItemsAsync()); RefreshCommand = new Command(async () => await RefreshItemsAsync());
// Task task = LoadDay(DateTime.Today);
// Beim Startup NICHT direkt im CTOR laden (kann Startup/Navigation blockieren)
// Stattdessen via Dispatcher "nach" dem Aufbau starten:
MainThread.BeginInvokeOnMainThread(async () =>
{
try
{
await LoadDay(DateTime.Today);
}
catch (Exception ex)
{
AlertEvent?.Invoke(this, ex.Message);
}
});
} }
@@ -231,7 +215,6 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
" installiert)"); " installiert)");
} }
//_hour = await HoursBase.LoadData();
RefreshProperties(); RefreshProperties();
} catch (Exception e) { } catch (Exception e) {
AlertEvent?.Invoke(this, e.Message); AlertEvent?.Invoke(this, e.Message);
@@ -244,16 +227,14 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// </summary> /// </summary>
public async Task LoadDay(DateTime date) { public async Task LoadDay(DateTime date) {
// kleine Initialwerte sind ok, aber UI-Thread sicher setzen: // kleine Initialwerte sind ok, aber UI-Thread sicher setzen:
await MainThread.InvokeOnMainThreadAsync(() => await MainThread.InvokeOnMainThreadAsync(() => {
{
DayTotal = new TimeOnly(0); DayTotal = new TimeOnly(0);
Sollstunden = new TimeOnly(0); Sollstunden = new TimeOnly(0);
}); });
try { try {
var (dayTimes, settings) = await _hoursService.GetDayWithSettingsAsync(date); var (dayTimes, settings) = await _hoursService.GetDayWithSettingsAsync(date);
await MainThread.InvokeOnMainThreadAsync(() => await MainThread.InvokeOnMainThreadAsync(() => {
{
DayTimes = dayTimes; DayTimes = dayTimes;
Settings = settings; Settings = settings;
GemeindeAktivSet = Settings.GemeindeAktivSet; GemeindeAktivSet = Settings.GemeindeAktivSet;
@@ -275,8 +256,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
} }
_soll = Settings.Nominal.Where(w => w.Timetable == dt.TimeTable && w.Wochentag == dt.Wday).ToList(); _soll = Settings.Nominal.Where(w => w.Timetable == dt.TimeTable && w.Wochentag == dt.Wday).ToList();
if (_soll.Count > 0) if (_soll.Count > 0) {
{
var soll = TimeOnly.FromTimeSpan(TimeSpan.FromHours(_soll[0].Zeit)); var soll = TimeOnly.FromTimeSpan(TimeSpan.FromHours(_soll[0].Zeit));
await MainThread.InvokeOnMainThreadAsync(() => Sollstunden = soll); await MainThread.InvokeOnMainThreadAsync(() => Sollstunden = soll);
} }
@@ -287,9 +267,8 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
//Nach der Tagessumme die anderen Tage anhängen //Nach der Tagessumme die anderen Tage anhängen
if (DayTimes != null) { if (DayTimes != null) {
var more = await _hoursService.GetDayRangeAsync(date, date.AddDays(3)); var more = await _hoursService.GetDayRangeAsync(date.AddDays(1), date.AddDays(3));
if (more != null && more.Count > 0) if (more != null && more.Count > 0) {
{
await MainThread.InvokeOnMainThreadAsync(() => await MainThread.InvokeOnMainThreadAsync(() =>
DayTimes = DayTimes.Concat(more).ToList() DayTimes = DayTimes.Concat(more).ToList()
); );
@@ -297,8 +276,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
} }
} catch (Exception e) { } catch (Exception e) {
await MainThread.InvokeOnMainThreadAsync(() => await MainThread.InvokeOnMainThreadAsync(() => {
{
DayTimes = new List<DayTime>(); DayTimes = new List<DayTime>();
//TODO: hier könnte auch ein Fehler kommen, dann wäre InfoEvent falsch. //TODO: hier könnte auch ein Fehler kommen, dann wäre InfoEvent falsch.
@@ -314,8 +292,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
} finally { } finally {
await MainThread.InvokeOnMainThreadAsync(() => await MainThread.InvokeOnMainThreadAsync(() => {
{
OnPropertyChanged(nameof(DayTotal)); OnPropertyChanged(nameof(DayTotal));
OnPropertyChanged(nameof(Sollstunden)); OnPropertyChanged(nameof(Sollstunden));
OnPropertyChanged(nameof(DateToday)); OnPropertyChanged(nameof(DateToday));
@@ -345,7 +322,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary> /// <summary>
/// Refreshes all properties /// Refreshes all properties
/// </summary> /// </summary>
private void RefreshProperties() { public void RefreshProperties() {
OnPropertyChanged(nameof(Hours)); OnPropertyChanged(nameof(Hours));
OnPropertyChanged(nameof(Title)); OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Nominal)); OnPropertyChanged(nameof(Nominal));

View File

@@ -1,3 +1,4 @@
using Jugenddienst_Stunden.Interfaces;
using Jugenddienst_Stunden.Models; using Jugenddienst_Stunden.Models;
using Jugenddienst_Stunden.Types; using Jugenddienst_Stunden.Types;
using Jugenddienst_Stunden.ViewModels; using Jugenddienst_Stunden.ViewModels;
@@ -10,9 +11,6 @@ namespace Jugenddienst_Stunden.Views;
/// Die Loginseite mit dem Barcodescanner /// Die Loginseite mit dem Barcodescanner
/// </summary> /// </summary>
public partial class LoginPage : ContentPage { public partial class LoginPage : ContentPage {
private DateTime _lastDetectionTime;
private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5);
/// <summary> /// <summary>
/// CTOR /// CTOR
@@ -24,7 +22,7 @@ public partial class LoginPage : ContentPage {
try { try {
if (BindingContext is null) { if (BindingContext is null) {
var sp = Application.Current?.Handler?.MauiContext?.Services var sp = Application.Current?.Handler?.MauiContext?.Services
?? throw new InvalidOperationException("DI container ist nicht verfügbar."); ?? throw new InvalidOperationException("DI container ist nicht verfügbar.");
BindingContext = sp.GetRequiredService<LoginViewModel>(); BindingContext = sp.GetRequiredService<LoginViewModel>();
} }
} catch (Exception) { } catch (Exception) {
@@ -59,155 +57,31 @@ public partial class LoginPage : ContentPage {
} }
}; };
//if (BindingContext is LoginViewModel vm) {
// vm.AlertEvent += Vm_AlertEvent;
// vm.InfoEvent += Vm_InfoEvent;
// vm.MsgEvent += Vm_MsgEvent;
//}
//Zwischen manuellem und automatischem Login (mit QR-Code) umschalten und die Schalterstellung merken
// MVVM übernimmt Umschalten über IsManualMode im ViewModel; keine Code-Behind-Umschaltung mehr
} }
/// <summary>
/// Nach der Erkennung des Barcodes wird der Benutzer eingeloggt
/// ZXing.Net.Maui.Controls 0.4.4
/// </summary>
private void BarcodesDetected(object sender, BarcodeDetectionEventArgs e) {
var currentTime = DateTime.Now;
if ((currentTime - _lastDetectionTime) > _detectionInterval) {
_lastDetectionTime = currentTime;
foreach (var barcode in e.Results) {
if (GlobalVar.ApiKey != barcode.Value) {
_ = MainThread.InvokeOnMainThreadAsync(async () => {
//await DisplayAlert("Barcode erkannt", $"Barcode: {barcode.Format} - {barcode.Value}", "OK");
try {
var tokendata = new TokenData(barcode.Value);
GlobalVar.ApiUrl = tokendata.Url;
User user = await HoursBase.LoadUser(barcode.Value);
GlobalVar.ApiKey = barcode.Value;
GlobalVar.Name = user.Name;
GlobalVar.Surname = user.Surname;
GlobalVar.EmployeeId = user.Id;
Title = user.Name + " " + user.Surname;
//Auf der Loginseite wird der Server als Info ohne Protokoll und ohne /appapi angezeigt
ServerLabel.Text = "Server: " + tokendata.Url.Replace("/appapi", "").Replace("https://", "")
.Replace("http://", "");
await DisplayAlert("Login erfolgreich", user.Name + " " + user.Surname, "OK");
if (Navigation.NavigationStack.Count > 1) {
//Beim ersten Start ohne Login, wird man automatisch auf die Loginseite geleitet. Danach in der History zur<75>ck
await Navigation.PopAsync();
} else {
//Beim manuellen Wechsel auf die Loginseite leiten wir nach erfolgreichem Login auf die Stunden<65>bersicht
await Shell.Current.GoToAsync($"//StundenPage");
}
} catch (Exception e) {
await DisplayAlert("Fehler", e.Message, "OK");
}
});
} else {
MainThread.InvokeOnMainThreadAsync(() => {
DisplayAlert("Bereits eingeloggt",
Preferences.Default.Get("name", "") + " " + Preferences.Default.Get("surname", ""),
"OK");
});
}
}
}
}
protected override void OnDisappearing() { protected override void OnDisappearing() {
base.OnDisappearing(); base.OnDisappearing();
barcodeScannerView.CameraLocation = CameraLocation.Front; barcodeScannerView.CameraLocation = CameraLocation.Front;
// IsDetecting wird via Binding vom ViewModel gesteuert // Scanner deaktivieren, wenn Seite verlassen wird
if (BindingContext is LoginViewModel vm) {
vm.IsDetecting = false;
}
} }
protected override void OnAppearing() { protected override void OnAppearing() {
base.OnAppearing(); base.OnAppearing();
// IsDetecting wird via Binding vom ViewModel gesteuert if (BindingContext is LoginViewModel vm) {
vm.RefreshSettings();
// Scanner wieder aktivieren, wenn QR-Modus aktiv ist
vm.IsDetecting = !vm.IsManualMode;
}
barcodeScannerView.CameraLocation = CameraLocation.Rear; barcodeScannerView.CameraLocation = CameraLocation.Rear;
} }
public bool IsCameraAvailable() {
var status = Permissions.CheckStatusAsync<Permissions.Camera>().Result;
if (status != PermissionStatus.Granted) {
status = Permissions.RequestAsync<Permissions.Camera>().Result;
}
return status != PermissionStatus.Granted;
}
private async void OnLoginButtonClicked(object sender, EventArgs e) {
var username = UsernameEntry.Text;
var password = PasswordEntry.Text;
var server = ServerEntry.Text;
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password) || string.IsNullOrEmpty(server)) {
await DisplayAlert("Fehler", "Bitte alle Felder ausf<73>llen", "OK");
return;
}
try {
Uri uri = new Uri(InputUrlWithSchema(server));
Types.User response =
await BaseFunc.AuthUserPass(username, password, uri.Scheme + "://" + uri.Authority + "/appapi");
GlobalVar.ApiKey = response.Token;
GlobalVar.Name = response.Name;
GlobalVar.Surname = response.Surname;
GlobalVar.EmployeeId = response.Id;
GlobalVar.ApiUrl = uri.Scheme + "://" + uri.Authority + "/appapi";
Title = response.Name + " " + response.Surname;
//ServerLabel.Text = "Server: " + server.Replace("/appapi", "").Replace("https://", "").Replace("http://", "");
ServerLabel.Text = "Server: " + uri.Authority;
await DisplayAlert("Login erfolgreich", response.Name + " " + response.Surname, "OK");
if (Navigation.NavigationStack.Count > 1)
await Navigation.PopAsync();
else {
await Shell.Current.GoToAsync($"//StundenPage");
}
} catch (Exception ex) {
await DisplayAlert("Fehler", ex.Message, "OK");
}
}
/// <summary>
/// Aus einer URL ohne Schema eine URL mit Schema machen
/// </summary>
private static string InputUrlWithSchema(string url) {
if (!url.StartsWith("http://") && !url.StartsWith("https://")) {
url = "https://" + url;
}
if (url.StartsWith("http://")) {
url = url.Replace("http://", "https://");
}
return url;
}
//Zwischen manuellem und automatischem Login (mit QR-Code) umschalten und die Schalterstellung merken
// Umschalt-Logik erfolgt über Binding an IsManualMode im ViewModel
//private void Vm_AlertEvent(object? sender, string e) {
// DisplayAlert("Fehler:", e, "OK");
//}
//private void Vm_InfoEvent(object? sender, string e) {
// DisplayAlert("Information:", e, "OK");
//}
//private async Task Vm_MsgEvent(string title, string message) {
// await DisplayAlert(title, message, "OK");
//}
} }

View File

@@ -8,10 +8,6 @@
x:Class="Jugenddienst_Stunden.Views.StundePage" x:Class="Jugenddienst_Stunden.Views.StundePage"
Title="{Binding Title}"> Title="{Binding Title}">
<ContentPage.BindingContext>
<models:StundeViewModel />
</ContentPage.BindingContext>
<ContentPage.Resources> <ContentPage.Resources>
<ResourceDictionary> <ResourceDictionary>
<conv:IntBoolConverter x:Key="IntBoolConverter" /> <conv:IntBoolConverter x:Key="IntBoolConverter" />
@@ -25,64 +21,81 @@
StatusBarStyle="LightContent" /> StatusBarStyle="LightContent" />
</ContentPage.Behaviors> </ContentPage.Behaviors>
<VerticalStackLayout Spacing="10" Margin="10"> <Grid Padding="10,0,10,0">
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="45"/>
<RowDefinition Height="20"/>
<RowDefinition Height="40"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Label Text="{Binding SubTitle}" FontSize="Medium" FontAttributes="Bold" Margin="4,0,0,0" />
<Border>
<Border.Padding>
<OnPlatform x:TypeArguments="Thickness" Default="0,15,10,0">
<On Platform="Android" Value="0,4,10,8" />
<On Platform="WPF" Value="0,15,10,0" />
</OnPlatform>
</Border.Padding>
<FlexLayout Direction="Row" AlignItems="Start" Wrap="Wrap" JustifyContent="SpaceBetween"> <Label Text="{Binding SubTitle}" FontSize="Medium" FontAttributes="Bold" Margin="4,0,0,0" Grid.Row="0" />
<HorizontalStackLayout Spacing="10">
<Label Text="Beginn" VerticalTextAlignment="Center" HorizontalTextAlignment="End" <FlexLayout Direction="Row" AlignItems="Start" Wrap="Wrap" AlignContent="Start" JustifyContent="Start" Grid.Row="1" Margin="0,0,0,10" >
<Border Margin="0,0,0,10" MinimumHeightRequest="72" FlexLayout.Grow="1">
<Border.Padding>
<OnPlatform x:TypeArguments="Thickness" Default="0,15,10,0">
<On Platform="Android" Value="0,4,10,8" />
<On Platform="WPF" Value="0,15,10,0" />
</OnPlatform>
</Border.Padding>
<FlexLayout Direction="Row" AlignItems="Start" Wrap="Wrap" JustifyContent="Start" AlignContent="Start">
<HorizontalStackLayout Spacing="10">
<Label Text="Beginn" VerticalTextAlignment="Center" HorizontalTextAlignment="End"
MinimumWidthRequest="60"> MinimumWidthRequest="60">
</Label> </Label>
<TimePicker x:Name="TimeBegin" HorizontalOptions="Center" Format="HH:mm" MinimumWidthRequest="80" <TimePicker x:Name="TimeBegin" HorizontalOptions="Center" Format="HH:mm" MinimumWidthRequest="80"
Time="{Binding DayTime.TimeSpanVon}" /> Time="{Binding DayTime.TimeSpanVon}" />
</HorizontalStackLayout> </HorizontalStackLayout>
<HorizontalStackLayout Spacing="10">
<Label Text="Ende" VerticalTextAlignment="Center" HorizontalTextAlignment="End"
MinimumWidthRequest="60">
</Label>
<TimePicker x:Name="TimeEnd" Format="HH:mm" MinimumWidthRequest="80"
Time="{Binding DayTime.TimeSpanBis}" />
</HorizontalStackLayout>
</FlexLayout>
</Border>
<Border FlexLayout.Grow="1">
<Border.Padding>
<OnPlatform x:TypeArguments="Thickness" Default="5">
<On Platform="Android" Value="5,4,5,8" />
<On Platform="WPF" Value="5" />
</OnPlatform>
</Border.Padding>
<HorizontalStackLayout Spacing="10"> <HorizontalStackLayout Spacing="10">
<Label Text="Ende" VerticalTextAlignment="Center" HorizontalTextAlignment="End" <Picker x:Name="pick_gemeinde" Title="Gemeinde" ItemsSource="{Binding OptionsGemeinde}"
MinimumWidthRequest="60">
</Label>
<TimePicker x:Name="TimeEnd" Format="HH:mm" MinimumWidthRequest="80"
Time="{Binding DayTime.TimeSpanBis}" />
</HorizontalStackLayout>
</FlexLayout>
</Border>
<Border>
<Border.Padding>
<OnPlatform x:TypeArguments="Thickness" Default="5">
<On Platform="Android" Value="5,4,5,8" />
<On Platform="WPF" Value="5" />
</OnPlatform>
</Border.Padding>
<HorizontalStackLayout>
<Picker x:Name="pick_gemeinde" Title="Gemeinde" ItemsSource="{Binding OptionsGemeinde}"
SelectedItem="{Binding DayTime.GemeindeAktiv, Mode=TwoWay}" ItemDisplayBinding="{Binding Name}" SelectedItem="{Binding DayTime.GemeindeAktiv, Mode=TwoWay}" ItemDisplayBinding="{Binding Name}"
IsVisible="{Binding GemeindeAktivSet}"> IsVisible="{Binding GemeindeAktivSet}">
</Picker> </Picker>
<Picker x:Name="pick_projekt" Title="Projekt" ItemsSource="{Binding OptionsProjekt}" <Picker x:Name="pick_projekt" Title="Projekt" ItemsSource="{Binding OptionsProjekt}"
SelectedItem="{Binding DayTime.ProjektAktiv, Mode=TwoWay}" ItemDisplayBinding="{Binding Name}" SelectedItem="{Binding DayTime.ProjektAktiv, Mode=TwoWay}" ItemDisplayBinding="{Binding Name}"
IsVisible="{Binding ProjektAktivSet}"> IsVisible="{Binding ProjektAktivSet}">
</Picker> </Picker>
<Picker x:Name="pick_freistellung" Title="Freistellung" ItemsSource="{Binding OptionsFreistellung}" <Picker x:Name="pick_freistellung" Title="Freistellung" ItemsSource="{Binding OptionsFreistellung}"
SelectedItem="{Binding DayTime.FreistellungAktiv, Mode=TwoWay}" SelectedItem="{Binding DayTime.FreistellungAktiv, Mode=TwoWay}"
ItemDisplayBinding="{Binding Name}" IsEnabled="{Binding FreistellungEnabled}"> ItemDisplayBinding="{Binding Name}" IsEnabled="{Binding FreistellungEnabled}">
</Picker> </Picker>
</HorizontalStackLayout> </HorizontalStackLayout>
</Border> </Border>
</FlexLayout>
<Editor Placeholder="Beschreibung" Text="{Binding DayTime.Description}" MinimumHeightRequest="40" <Editor Placeholder="Beschreibung" Text="{Binding DayTime.Description}" MinimumHeightRequest="40"
AutoSize="TextChanges" FontSize="18" /> AutoSize="TextChanges" FontSize="18" Grid.Row="2" Margin="0,0,0,10" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="4"> <Grid ColumnDefinitions="*,*" ColumnSpacing="4" Grid.Row="3">
<Button Grid.Column="1" Text="Speichern" <Button Grid.Column="1" Text="Speichern"
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}"
Command="{Binding SaveCommand}" /> Command="{Binding SaveCommand}" />
@@ -95,14 +108,14 @@
</Grid> </Grid>
<BoxView HeightRequest="1" Margin="3,10" /> <BoxView HeightRequest="1" Margin="3,10" Grid.Row="4" />
<Label Text="Noch keine Einträge vorhanden" <Label Text="Noch keine Einträge vorhanden"
IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}, ConverterParameter=Invert}" IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}, ConverterParameter=Invert}"
Margin="6,0,0,0" /> Margin="6,0,0,0" Grid.Row="5" />
<StackLayout Margin="6,0,0,0" <StackLayout Margin="6,0,0,0"
IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}"> IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}" Grid.Row="5">
<Label> <Label>
<Label.FormattedText> <Label.FormattedText>
<FormattedString> <FormattedString>
@@ -113,48 +126,52 @@
</Label> </Label>
</StackLayout> </StackLayout>
<ScrollView IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}">
<CollectionView <CollectionView
ItemsSource="{Binding DayTimes}" ItemsSource="{Binding DayTimes}"
x:Name="stundeItems" Margin="0" x:Name="stundeItems" Margin="0"
HeightRequest="350"
SelectionMode="Single" SelectionMode="Single"
VerticalOptions="Start"
SelectionChangedCommand="{Binding SelectEntryCommand}" SelectionChangedCommand="{Binding SelectEntryCommand}"
SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}"> SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}"
IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}"
Grid.Row="6">
<CollectionView.ItemsLayout> <CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="0" /> <LinearItemsLayout Orientation="Vertical" ItemSpacing="0" />
</CollectionView.ItemsLayout> </CollectionView.ItemsLayout>
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
<DataTemplate> <DataTemplate>
<Grid Padding="5,10,5,0"> <Grid Padding="5,10,5,0">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<HorizontalStackLayout Grid.Row="0" Grid.Column="0"> <HorizontalStackLayout Grid.Row="0" Grid.Column="0">
<Label Grid.Column="0" Text="{Binding Begin}" /> <Label Grid.Column="0" Text="{Binding Begin}" />
<Label Text="bis" Padding="5,0,5,0" /> <Label Text="bis" Padding="5,0,5,0" />
<Label Text="{Binding End}" /> <Label Text="{Binding End}" />
<Label Text="{Binding GemeindeAktiv.Name}" Margin="10,0,0,0" <Label Text="{Binding GemeindeAktiv.Name}" Margin="10,0,0,0"
IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.GemeindeAktivSet}" /> IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.GemeindeAktivSet}" />
<Label Text="{Binding ProjektAktiv.Name}" Margin="10,0,0,0" <Label Text="{Binding ProjektAktiv.Name}" Margin="10,0,0,0"
IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ProjektAktivSet}" /> IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ProjektAktivSet}" />
<Label Text="{Binding FreistellungAktiv.Name}" Margin="10,0,0,0" /> <Label Text="{Binding FreistellungAktiv.Name}" Margin="10,0,0,0" />
</HorizontalStackLayout> </HorizontalStackLayout>
<Label Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Text="{Binding Description}" <Label Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Text="{Binding Description}"
Padding="0,0,0,15" /> Padding="0,0,0,15" />
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</CollectionView.ItemTemplate> </CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</CollectionView>
</ScrollView>
</VerticalStackLayout>
</ContentPage> </ContentPage>

View File

@@ -13,14 +13,13 @@ public partial class StundePage : ContentPage {
/// <summary> /// <summary>
/// CTOR /// CTOR
/// </summary> /// </summary>
public StundePage() { public StundePage(StundeViewModel vm) {
InitializeComponent(); InitializeComponent();
if (BindingContext is StundeViewModel vm) { BindingContext = vm;
vm.AlertEvent += Vm_AlertEvent; vm.AlertEvent += Vm_AlertEvent;
vm.InfoEvent += Vm_InfoEvent; vm.InfoEvent += Vm_InfoEvent;
vm.ConfirmEvent += ShowConfirm; vm.ConfirmEvent += ShowConfirm;
}
} }
private void Vm_AlertEvent(object? sender, string e) { private void Vm_AlertEvent(object? sender, string e) {
@@ -41,12 +40,4 @@ public partial class StundePage : ContentPage {
}); });
} }
//private async Task<bool> ShowConfirm(string title, string message, string ok, string not_ok) {
// return await DisplayAlert(title, message, ok, not_ok);
//}
//private async void ShowConfirm(object? sender, ConfirmEventArgs e) {
// bool result = await DisplayAlert(e.Title, e.Message, e.Ok, e.NotOk);
// e.Result = result;
//}
} }

View File

@@ -13,6 +13,7 @@
<ContentPage.Resources> <ContentPage.Resources>
<ResourceDictionary> <ResourceDictionary>
<conv:SecondsTimeConverter x:Key="secToTime" /> <conv:SecondsTimeConverter x:Key="secToTime" />
<conv:AnyTrueMultiConverter x:Key="AnyTrue"/>
<FontImageSource x:Key="ToolbarIcon" <FontImageSource x:Key="ToolbarIcon"
Glyph="+" Glyph="+"
Size="22" Size="22"
@@ -27,7 +28,7 @@
</ContentPage.Behaviors> </ContentPage.Behaviors>
<ContentPage.ToolbarItems> <ContentPage.ToolbarItems>
<!--<ToolbarItem Text="Lade Liste" Command="{Binding RefreshListCommand}"/>-->
<ToolbarItem Text="Neuer Eintrag" IconImageSource="{StaticResource ToolbarIcon}" <ToolbarItem Text="Neuer Eintrag" IconImageSource="{StaticResource ToolbarIcon}"
Command="{Binding NewEntryCommand}" /> Command="{Binding NewEntryCommand}" />
</ContentPage.ToolbarItems> </ContentPage.ToolbarItems>
@@ -35,7 +36,6 @@
<RefreshView x:Name="MyRefreshView" Command="{Binding RefreshCommand}" IsRefreshing="{Binding IsRefreshing}" <RefreshView x:Name="MyRefreshView" Command="{Binding RefreshCommand}" IsRefreshing="{Binding IsRefreshing}"
Margin="10" Padding="10"> Margin="10" Padding="10">
<Grid RowDefinitions="50,*,Auto,80"> <Grid RowDefinitions="50,*,Auto,80">
<!--<VerticalStackLayout Spacing="10" Margin="10">-->
<Grid RowDefinitions="Auto" ColumnDefinitions="Auto,*" HeightRequest="50" Grid.Row="0"> <Grid RowDefinitions="Auto" ColumnDefinitions="Auto,*" HeightRequest="50" Grid.Row="0">
<DatePicker Grid.Column="0" MinimumDate="{Binding MinimumDate}" <DatePicker Grid.Column="0" MinimumDate="{Binding MinimumDate}"
@@ -53,7 +53,6 @@
<CollectionView <CollectionView
ItemsSource="{Binding DayTimes}" ItemsSource="{Binding DayTimes}"
x:Name="stundeItems" Margin="0,0,0,20" x:Name="stundeItems" Margin="0,0,0,20"
SelectionMode="Single" SelectionMode="Single"
SelectionChangedCommand="{Binding SelectEntryCommand}" SelectionChangedCommand="{Binding SelectEntryCommand}"
SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}" SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}"
@@ -61,79 +60,83 @@
Grid.Row="1"> Grid.Row="1">
<CollectionView.ItemsLayout> <CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="0" /> <LinearItemsLayout Orientation="Vertical" ItemSpacing="10" />
</CollectionView.ItemsLayout> </CollectionView.ItemsLayout>
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
<DataTemplate> <DataTemplate>
<VerticalStackLayout Padding="5,10,5,0"> <Border Padding="5" StrokeShape="RoundRectangle 0,30,0,30" >
<Border.Triggers>
<DataTrigger TargetType="Border" Binding="{Binding Approved}" Value="True">
<Setter Property="Background" Value="LightCoral"/>
<Setter Property="Padding" Value="4"/>
</DataTrigger>
</Border.Triggers>
<VisualStateManager.VisualStateGroups> <VisualStateManager.VisualStateGroups>
<VisualStateGroup Name="CommonStates"> <VisualStateGroup Name="CommonStates">
<VisualState Name="Normal"> <VisualState Name="Normal">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="BackgroundColor" <Setter Property="BackgroundColor"
Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource TransparentColor}}" /> Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource TransparentColor}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState Name="Selected"> <VisualState Name="Selected">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="BackgroundColor" <Setter Property="BackgroundColor"
Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" /> Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateManager.VisualStateGroups> </VisualStateManager.VisualStateGroups>
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*" >
<HorizontalStackLayout> <HorizontalStackLayout Grid.Row="0" >
<HorizontalStackLayout.Triggers> <Label Text="{Binding Day, StringFormat='{0:dddd, dd. MMMM}'}" />
<DataTrigger TargetType="HorizontalStackLayout" Binding="{Binding Approved}" <Label Text="von" Padding="5,0,5,0" />
Value="True"> <Label Text="{Binding Begin}" />
<Setter Property="BackgroundColor" Value="LightCoral" /> <Label Text="bis" Padding="5,0,5,0" />
<Setter Property="Padding" Value="4" /> <Label Text="{Binding End}" />
</DataTrigger> </HorizontalStackLayout>
</HorizontalStackLayout.Triggers>
<Label Text="{Binding Day, StringFormat='{0:dddd, dd. MMMM}'}" />
<Label Text="von" Padding="5,0,5,0" />
<Label Text="{Binding Begin}" />
<Label Text="bis" Padding="5,0,5,0" />
<Label Text="{Binding End}" />
</HorizontalStackLayout>
<HorizontalStackLayout HorizontalOptions="FillAndExpand"> <HorizontalStackLayout Grid.Row="1" >
<HorizontalStackLayout.Triggers> <!--<HorizontalStackLayout.IsVisible>
<DataTrigger TargetType="HorizontalStackLayout" Binding="{Binding Approved}" <MultiBinding Converter="{StaticResource AnyTrue}">
Value="True"> <Binding Path="Approved"/>
<Setter Property="BackgroundColor" Value="LightCoral" /> <Binding Path="BindingContext.GemeindeAktivSet" Source="{RelativeSource AncestorType={x:Type ContentPage}}"/>
<Setter Property="Padding" Value="4" /> <Binding Path="BindingContext.ProjektAktivSet" Source="{RelativeSource AncestorType={x:Type ContentPage}}"/>
</DataTrigger> </MultiBinding>
</HorizontalStackLayout.Triggers> </HorizontalStackLayout.IsVisible>-->
<Label Text="{Binding GemeindeAktiv.Name}" Margin="0,0,10,0" <Label Text="{Binding GemeindeAktiv.Name}" Padding="0,0,10,0"
IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.GemeindeAktivSet}" /> IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.GemeindeAktivSet}" />
<Label Text="{Binding ProjektAktiv.Name}" Margin="0,0,10,0" <Label Text="{Binding ProjektAktiv.Name}" Padding="0,0,10,0"
IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ProjektAktivSet}" /> IsVisible="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ProjektAktivSet}" />
<Label Text="{Binding FreistellungAktiv.Name}" IsVisible="{Binding Approved}" /> <Label Text="{Binding FreistellungAktiv.Name}" IsVisible="{Binding Approved}" />
</HorizontalStackLayout> </HorizontalStackLayout>
<!-- Ensure description row does not paint background -->
<Label Text="{Binding Description}" Grid.Row="2" />
</Grid>
</Border>
<Label Text="{Binding Description}" Padding="0,0,0,15" />
</VerticalStackLayout>
</DataTemplate> </DataTemplate>
</CollectionView.ItemTemplate> </CollectionView.ItemTemplate>
</CollectionView> </CollectionView>
<!--<BoxView HeightRequest="1" Grid.Row="2" Margin="0,5,0,15" />-->
<Button Text="{Binding LoadOverview}" <Button Text="{Binding LoadOverview}"
Grid.Row="2" Grid.Row="2"
Command="{Binding LoadDataCommand}" Command="{Binding LoadDataCommand}"
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" /> TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" />
<Border Padding="2" Grid.Row="3" Margin="0,10,0,0"> <Border Padding="2" Grid.Row="3" Margin="0,10,0,0">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*" ColumnDefinitions="Auto,Auto,*,Auto" Margin="10,2"> <Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,Auto,*" Margin="10,2">
<Label Grid.Row="0" Text="Soll:" /> <Label Grid.Row="0" Text="Soll:" />
<Label Grid.Row="1" Text="Summe:" /> <Label Grid.Row="1" Text="Summe:" />
<Label Grid.Row="2" Text="Differenz:" Margin="0,0,15,0" /> <Label Grid.Row="2" Text="Differenz:" Margin="0,0,15,0" />
<!--<BoxView Grid.Row="3" Grid.ColumnSpan="4" HeightRequest="1" Color="LightGrey" Margin="0,5"/>-->
<Label Grid.Row="0" Grid.Column="2" Text="Restüberstunden:" Margin="15,0,0,0" /> <Label Grid.Row="0" Grid.Column="2" Text="Restüberstunden:" Margin="15,0,0,0" />
<Label Grid.Row="1" Grid.Column="2" Text="Zeitausgleich:" Margin="15,0,0,0" /> <Label Grid.Row="1" Grid.Column="2" Text="Zeitausgleich:" Margin="15,0,0,0" />
<Label Grid.Row="2" Grid.Column="2" Text="Resturlaub:" Margin="15,0,0,0" /> <Label Grid.Row="2" Grid.Column="2" Text="Resturlaub:" Margin="15,0,0,0" />
@@ -156,7 +159,7 @@
</Border> </Border>
</Grid> </Grid>
<!--</VerticalStackLayout>-->
</RefreshView> </RefreshView>
</ContentPage> </ContentPage>

View File

@@ -28,24 +28,13 @@ public partial class StundenPage : ContentPage {
vm.AlertEvent += Vm_AlertEvent; vm.AlertEvent += Vm_AlertEvent;
vm.InfoEvent += Vm_InfoEvent; vm.InfoEvent += Vm_InfoEvent;
// Navigation NICHT im CTOR ausführen (Shell/Navigation-Stack ist hier oft noch nicht ?ready?)
// if (!CheckLogin()) {
// NavigateToTargetPage();
// }
} }
private void Vm_AlertEvent(object? sender, string e) { private void Vm_AlertEvent(object? sender, string e) {
MainThread.BeginInvokeOnMainThread(async () => { await DisplayAlert("Fehler:", e, "OK"); }); MainThread.BeginInvokeOnMainThread(async () => { await DisplayAlert("Fehler:", e, "OK"); });
} }
//private void Vm_InfoEvent(object? sender, string e) {
// DisplayAlert("Information:", e, "OK");
//}
//private void Vm_InfoEvent(object? sender, string e) {
// MainThread.BeginInvokeOnMainThread(async () => {
// await DisplayAlert("Information:", e, "OK");
// });
//}
private void Vm_InfoEvent(object? sender, string e) { private void Vm_InfoEvent(object? sender, string e) {
MainThread.BeginInvokeOnMainThread(async () => { MainThread.BeginInvokeOnMainThread(async () => {
CancellationTokenSource cts = new CancellationTokenSource(); CancellationTokenSource cts = new CancellationTokenSource();
@@ -69,6 +58,12 @@ public partial class StundenPage : ContentPage {
} catch (Exception ex) { } catch (Exception ex) {
await DisplayAlert("Fehler:", ex.Message, "OK"); await DisplayAlert("Fehler:", ex.Message, "OK");
} }
} else {
// Wenn eingeloggt, sicherstellen dass die Daten aktuell sind (besonders nach dem Login)
if (BindingContext is StundenViewModel vm) {
vm.RefreshProperties(); // Aktualisiert den Titel (Name/Vorname)
await vm.LoadDay(vm.DateToday);
}
} }
} }
@@ -76,9 +71,6 @@ public partial class StundenPage : ContentPage {
return Preferences.Default.Get("apiKey", "") != ""; return Preferences.Default.Get("apiKey", "") != "";
} }
// private async void NavigateToTargetPage() {
// await Navigation.PushAsync(new LoginPage());
// }
private Task NavigateToTargetPage() { private Task NavigateToTargetPage() {
// Shell-Navigation statt Navigation.PushAsync // Shell-Navigation statt Navigation.PushAsync