15 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
8d512963b5 Add InfoEvent with confirmation 2025-12-19 10:29:30 +01:00
30 changed files with 596 additions and 796 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 IAppSettings _settings;
public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) {
public ApiClient(HttpClient http, ApiOptions options, IAppSettings settings) {
_http = http;
_options = options;
_settings = settings;
@@ -47,9 +47,11 @@ internal sealed class ApiClient : IApiClient {
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
IDictionary<string, string?>? query = null, CancellationToken ct = default) {
// Absolute URI aus aktuellem SettingsBaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen.
var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query);
using var req = new HttpRequestMessage(method, uri);
// Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist)
// Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten.
// 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);
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)
throw ApiException.From(res.StatusCode, TryGetMessage(text), text);
if (res.StatusCode != System.Net.HttpStatusCode.OK) {
// Verhalten wie in BaseFunc: bei Fehlerstatus -> "message" aus Body lesen und mit dessen Inhalt eine Exception werfen.
try {
var options = new JsonDocumentOptions { AllowTrailingCommas = true };
@@ -127,24 +136,44 @@ internal sealed class ApiClient : IApiClient {
throw new InvalidOperationException(
"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
string relativePath = path ?? string.Empty;
if (relativePath.StartsWith('/'))
relativePath = relativePath.TrimStart('/');
if (query is not null && query.Count > 0) {
if (normalizedBase.EndsWith('/'))
normalizedBase = normalizedBase.TrimEnd('/');
var sb = new StringBuilder(relativePath);
sb.Append(relativePath.Contains('?') ? '&' : '?');
sb.Append(string.Join('&', query
.Where(kv => kv.Value is not null)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
relativePath = sb.ToString();
} else {
if (!normalizedBase.EndsWith('/'))
normalizedBase += "/";
}
var baseUri = new Uri(normalizedBase, UriKind.Absolute);
// Wenn path bereits absolut ist, direkt verwenden
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
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;
}
return new Uri(baseUri, relativePath);
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.Types;
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 {
get => Preferences.Default.Get("apiUrl", "");
set => Preferences.Default.Set("apiUrl", value);
@@ -27,4 +37,5 @@ internal sealed class PreferencesAppSettings : IAppSettings {
get => Preferences.Default.Get("surname", "Eingeloggt");
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;
namespace Jugenddienst_Stunden.Interfaces;
internal interface IAlertService {
public interface IAlertService {
event EventHandler<string> AlertRaised;
void Raise(string message);
}

View File

@@ -1,5 +1,8 @@
namespace Jugenddienst_Stunden.Interfaces;
/// <summary>
/// Defines methods for making HTTP requests to a specified API.
/// </summary>
internal interface IApiClient {
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;
/// <summary>
/// Represents the application settings required for configuration and operation of the application.
/// </summary>
public interface IAppSettings {
string ApiUrl { get; set; }
string ApiKey { get; set; }
@@ -7,4 +12,6 @@ public interface IAppSettings {
int EmployeeId { get; set; }
string Name { 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> SaveEntryAsync(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>
<!-- <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 -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
@@ -109,8 +109,8 @@
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PlatformTarget>AnyCPU</PlatformTarget>
<ProduceReferenceAssembly>False</ProduceReferenceAssembly>
<AssemblyVersion>1.0.9</AssemblyVersion>
<FileVersion>1.0.9</FileVersion>
<AssemblyVersion>1.1.0</AssemblyVersion>
<FileVersion>1.1.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net8.0-android|AnyCPU'">
@@ -176,76 +176,96 @@
</PropertyGroup>
<PropertyGroup>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.26100.0</TargetFrameworks>
<WindowsPackageType>None</WindowsPackageType>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.26100.0</TargetFrameworks>
<WindowsPackageType>MSIX</WindowsPackageType>
<!-- <TargetFrameworks>;net9.0-android35.0</TargetFrameworks> -->
</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>
<!-- 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 -->
<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) -->
<!--<MauiImage Include="Resources\Images\logo_splash_win.svg" Color="#F7931D" BaseSize="208,208" />-->
<!-- Images -->
<MauiImage Include="Resources\Images\*"/>
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185"/>
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*"/>
<MauiFont Include="Resources\Fonts\*" />
<!-- 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>
<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-150.png"/>
<None Remove="Resources\Windows\%24placeholder%24.scale-200.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-125.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-400.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-150.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\Wide\%24placeholder%24.scale-100.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-200.png"/>
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-400.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-150.png" />
<None Remove="Resources\Windows\%24placeholder%24.scale-200.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-125.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-400.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-150.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\Wide\%24placeholder%24.scale-100.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-200.png" />
<None Remove="Resources\Windows\Wide\%24placeholder%24.scale-400.png" />
</ItemGroup>
<ItemGroup>
<Content Include="Resources\Windows\$placeholder$.scale-100.png"/>
<Content Include="Resources\Windows\$placeholder$.scale-125.png"/>
<Content Include="Resources\Windows\$placeholder$.scale-150.png"/>
<Content Include="Resources\Windows\$placeholder$.scale-200.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-125.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-400.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-150.png"/>
<Content Include="Resources\Windows\Splash\$placeholder$.scale-200.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-125.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-400.png"/>
<Content Include="Resources\Windows\$placeholder$.scale-100.png" />
<Content Include="Resources\Windows\$placeholder$.scale-125.png" />
<Content Include="Resources\Windows\$placeholder$.scale-150.png" />
<Content Include="Resources\Windows\$placeholder$.scale-200.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-125.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-400.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-150.png" />
<Content Include="Resources\Windows\Splash\$placeholder$.scale-200.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-125.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-400.png" />
</ItemGroup>
<ItemGroup>
@@ -256,18 +276,19 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Maui" Version="12.2.0"/>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/>
<PackageReference Include="CommunityToolkit.Maui" Version="12.2.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.110">
<TreatAsUsed>true</TreatAsUsed>
</PackageReference>
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.110"/>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9"/>
<PackageReference Include="Microsoft.Maui.Graphics" Version="9.0.110"/>
<PackageReference Include="Microsoft.NET.Runtime.MonoAOTCompiler.Task" Version="9.0.9"/>
<PackageReference Include="Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk" Version="9.0.9"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.5.0"/>
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.110" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="Microsoft.Maui.Graphics" Version="9.0.110" />
<PackageReference Include="Microsoft.NET.Runtime.MonoAOTCompiler.Task" Version="9.0.9" />
<PackageReference Include="Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk" Version="9.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.5.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging;
using ZXing.Net.Maui.Controls;
using System.Net.Http;
using Jugenddienst_Stunden.ViewModels;
using Jugenddienst_Stunden.Views;
using System.Net;
namespace Jugenddienst_Stunden;
@@ -26,71 +28,36 @@ public static class MauiProgram {
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
//.UseBarcodeScanning();
.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)
builder.Services.AddSingleton<IAlertService, AlertService>();
// 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)
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
// Configure HttpClient with RequestLoggingHandler and disable automatic redirects for diagnosis
// HttpClient + ApiClient Best Practices:
// 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.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 => {
var alert = sp.GetRequiredService<IAlertService>();
try {
return new ApiClient(
sp.GetRequiredService<HttpClient>(),
sp.GetRequiredService<ApiOptions>(),
sp.GetRequiredService<ITokenProvider>(),
sp.GetRequiredService<IAppSettings>());
} catch (Exception e) {
// 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>()));
builder.Services.AddHttpClient<IApiClient, ApiClient>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler {
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
ConnectTimeout = TimeSpan.FromSeconds(10)
})
.AddHttpMessageHandler<RequestLoggingHandler>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
// DI: Validatoren
builder.Services.AddSingleton<IHoursValidator, HoursValidator>();
@@ -101,10 +68,12 @@ public static class MauiProgram {
builder.Services.AddSingleton<IAuthService, AuthService>();
// DI: Views/ViewModels
builder.Services.AddTransient<ViewModels.StundenViewModel>();
builder.Services.AddTransient<Views.StundenPage>();
builder.Services.AddTransient<ViewModels.LoginViewModel>();
builder.Services.AddTransient<Views.LoginPage>();
builder.Services.AddTransient<StundenViewModel>();
builder.Services.AddTransient<StundenPage>();
builder.Services.AddTransient<StundeViewModel>();
builder.Services.AddTransient<StundePage>();
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<LoginPage>();
return builder.Build();
}

View File

@@ -100,82 +100,7 @@ internal static class BaseFunc {
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

@@ -0,0 +1,21 @@
using System.Threading.Tasks;
namespace Jugenddienst_Stunden.Models;
public sealed class ConfirmationEventArgs : System.EventArgs {
public string Title { get; }
public string Message { get; }
public string ConfirmText { get; }
private readonly TaskCompletionSource<bool> _tcs = new();
public ConfirmationEventArgs(string title, string message, string confirmText = "OK") {
Title = title;
Message = message;
ConfirmText = confirmText;
}
public Task<bool> Task => _tcs.Task;
public void SetResult(bool result) => _tcs.TrySetResult(result);
}

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": {
"Windows Machine": {
"commandName": "Project",
"commandName": "MsixPackage",
"nativeDebugging": false
}
}

View File

@@ -62,7 +62,7 @@ internal class HoursRepository : IHoursRepository {
}
public Task DeleteEntry(DayTime stunde)
=> _api.DeleteAsync($"/entry/{stunde.Id}");
=> _api.DeleteAsync($"entry/{stunde.Id}");
private static Dictionary<string, string?> QueryToDictionary(string query) {
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 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) {
var settings = await _repo.LoadSettings();
_validator.Validate(stunde, settings);

View File

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

View File

@@ -4,24 +4,45 @@ using System.Collections.ObjectModel;
namespace Jugenddienst_Stunden.Types;
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 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<string> time_line;
public double? Zeit_total;
//https://stackoverflow.com/questions/29449641/deserialize-json-when-a-value-can-be-an-object-or-an-empty-array/29450279#29450279
//[JsonConverter(typeof(JsonSingleOrEmptyArrayConverter<Hours>))]
//public Dictionary<int,decimal> zeit_total_daily;
/// <summary>
/// Total time in seconds reported by the API for the current period. Nullable if not provided.
/// </summary>
public double? zeit_total { get; set; }
/// <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;
/// <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<string> wochensumme;
@@ -33,7 +54,7 @@ public partial class Hours : ObservableObject {
[ObservableProperty] public double zeitausgleich;
public double zeitausgleich_month;
public double holiday;
public double? holiday { get; set; }
public double krankheit;
public double weiterbildung;
public double bereitschaft;

View File

@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Jugenddienst_Stunden.Interfaces;
using Jugenddienst_Stunden.Models;
namespace Jugenddienst_Stunden.ViewModels;
@@ -9,13 +10,14 @@ namespace Jugenddienst_Stunden.ViewModels;
/// </summary>
public partial class LoginViewModel : ObservableObject {
private readonly IAuthService _auth;
private readonly IAppSettings _settings;
private readonly IAlertService? _alerts;
private readonly IAppSettings _settings;
private DateTime _lastDetectionTime = DateTime.MinValue;
private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5);
public event EventHandler<string>? AlertEvent;
public event EventHandler<string>? InfoEvent;
//public event EventHandler<string>? InfoEvent;
public event EventHandler<ConfirmationEventArgs>? InfoEvent;
/// <summary>
/// Name der Anwendung
@@ -37,7 +39,7 @@ public partial class LoginViewModel : ObservableObject {
private string? serverLabel;
[ObservableProperty]
private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt");
private string title = "Nicht eingeloggt";
[ObservableProperty]
private string? username;
@@ -68,22 +70,36 @@ public partial class LoginViewModel : ObservableObject {
IsDetecting = !isManualMode;
// Serveranzeige vorbereiten
var apiUrl = Preferences.Default.Get("apiUrl", string.Empty);
if (!string.IsNullOrWhiteSpace(apiUrl)) {
Server = apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", "");
ServerLabel = "Server: " + Server;
}
RefreshSettings();
// Command initialisieren
QrDetectedCommand = new AsyncRelayCommand<object?>(QrDetectedAsync);
}
// 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;
_settings = settings;
if (alertService is not null) {
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) {
@@ -102,9 +118,13 @@ public partial class LoginViewModel : ObservableObject {
(Server ?? string.Empty).Trim());
Title = $"{user.Name} {user.Surname}";
InfoEvent?.Invoke(this, "Login erfolgreich");
// Info zeigen und auf Bestätigung warten
var args = new ConfirmationEventArgs("Information:", "Login erfolgreich");
InfoEvent?.Invoke(this, args);
bool confirmed = await args.Task;
if (confirmed) {
await Shell.Current.GoToAsync("//StundenPage");
}
} catch (Exception ex) {
if (_alerts is not null) {
_alerts.Raise(ex.Message);
@@ -128,7 +148,13 @@ public partial class LoginViewModel : ObservableObject {
var user = await _auth.LoginWithToken(token);
Title = $"{user.Name} {user.Surname}";
// Info zeigen und auf Bestätigung warten
var infoArgs = new ConfirmationEventArgs("Information:", "Login erfolgreich");
InfoEvent?.Invoke(this, infoArgs);
bool confirmed = await infoArgs.Task;
if (confirmed) {
await Shell.Current.GoToAsync("//StundenPage");
}
} catch (Exception ex) {
if (_alerts is not null) {
_alerts.Raise(ex.Message);

View File

@@ -19,13 +19,13 @@ namespace Jugenddienst_Stunden.ViewModels;
/// </summary>
public partial class StundeViewModel : ObservableObject, IQueryAttributable {
private readonly IHoursService _hoursService;
private readonly IAppSettings _settings;
private readonly IAlertService _alertService;
public int Id { get; set; }
public string Title { get; set; } = "Eintrag bearbeiten";
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> InfoEvent;
@@ -74,52 +74,30 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
public ICommand SaveCommand { get; private set; }
public ICommand DeleteCommand { get; private set; }
public ICommand DeleteConfirmCommand { get; private set; }
public ICommand SelectEntryCommand { get; }
//public ICommand LoadDataCommand { get; private set; }
public StundeViewModel() : this(GetServiceOrCreate()) {
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) {
public StundeViewModel(IHoursService hoursService, IAlertService alertService, IAppSettings settings) {
_hoursService = hoursService;
_settings = settings;
_alertService = alertService;
SaveCommand = new AsyncRelayCommand(Save);
//DeleteCommand = new AsyncRelayCommand(Delete);
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() {
try {
Settings = await _hoursService.GetSettingsAsync();
GlobalVar.Settings = Settings;
private void UpdateSettings(Settings settings) {
_settings.Settings = settings;
OptionsGemeinde = settings.Gemeinden;
OptionsProjekt = settings.Projekte;
OptionsFreistellung = settings.Freistellungen;
OptionsGemeinde = Settings.Gemeinden;
OptionsProjekt = Settings.Projekte;
OptionsFreistellung = Settings.Freistellungen;
GemeindeAktivSet = Settings.GemeindeAktivSet;
ProjektAktivSet = Settings.ProjektAktivSet;
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
}
GemeindeAktivSet = settings.GemeindeAktivSet;
ProjektAktivSet = settings.ProjektAktivSet;
}
async Task Save() {
@@ -133,16 +111,22 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
}
//Projekt ist ein Pflichtfeld
if (Settings.ProjektAktivSet && DayTime.ProjektAktiv.Id == 0) {
if (_settings.Settings.ProjektAktivSet) {
var projektId = DayTime.ProjektAktiv?.Id ?? 0;
if (projektId == 0) {
proceed = false;
AlertEvent?.Invoke(this, "Projekt darf nicht leer sein");
}
}
//Gemeinde ist ein Pflichtfeld
if (Settings.GemeindeAktivSet && DayTime.GemeindeAktiv.Id == 0) {
if (_settings.Settings.GemeindeAktivSet) {
var gemeindeId = DayTime.GemeindeAktiv?.Id ?? 0;
if (gemeindeId == 0) {
proceed = false;
AlertEvent?.Invoke(this, "Gemeinde darf nicht leer sein");
}
}
if (proceed) {
try {
@@ -154,7 +138,8 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
if (!exceptionOccurred) {
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 {
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?");
if (answer) {
//Löschen
try {
await _hoursService.DeleteEntryAsync(DayTime);
await Shell.Current.GoToAsync($"..?date={DayTime.Day.ToString("yyyy-MM-dd")}");
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
}
} else {
//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>
/// Anwenden der Query-Parameter
/// </summary>
@@ -196,20 +198,15 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
if (query.ContainsKey("load")) {
//DateTime heute = DateTime.Now;
try {
var entry = await _hoursService.GetEntryAsync(Convert.ToInt32(query["load"]));
// var settings = await _hoursService.GetSettingsAsync();
// GlobalVar.Settings = settings;
// GemeindeAktivSet = settings.GemeindeAktivSet;
// ProjektAktivSet = settings.ProjektAktivSet;
//var entry = await _hoursService.GetEntryAsync(Convert.ToInt32(query["load"]));
var (entry, settings, daytimes) =
await _hoursService.GetEntryWithSettingsAsync(Convert.ToInt32(query["load"]));
UpdateSettings(settings);
DayTime = entry;
DayTime.TimeSpanVon = entry.Begin.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) ??
new Gemeinde();
DayTime.ProjektAktiv = OptionsProjekt.FirstOrDefault(Projekt => Projekt.Id == DayTime.Projekt) ??
@@ -218,16 +215,7 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
OptionsFreistellung.FirstOrDefault(Freistellung => Freistellung.Id == DayTime.Free) ??
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(DayTimes));
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
} finally {
}
if (System.String.IsNullOrEmpty(DayTime.Description)) {
InfoEvent?.Invoke(this, "Eintrag hat keinen Beschreibungstext");
}
@@ -236,6 +224,13 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
OnPropertyChanged(nameof(SubTitle));
FreistellungEnabled = !DayTime.Approved;
DayTimes = daytimes;
OnPropertyChanged(nameof(DayTimes));
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
}
//OnPropertyChanged(nameof(DayTime));
} else if (query.ContainsKey("date")) {
Title = "Neuer Eintrag";
@@ -246,24 +241,20 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
//Bei neuem Eintrag die vorhandenen des gleichen Tages anzeigen
try {
var (list, settings) = await _hoursService.GetDayWithSettingsAsync(_date);
GlobalVar.Settings = settings;
UpdateSettings(settings);
DayTimes = list;
OptionsGemeinde = settings.Gemeinden;
OptionsProjekt = settings.Projekte;
OptionsFreistellung = settings.Freistellungen;
GemeindeAktivSet = settings.GemeindeAktivSet;
ProjektAktivSet = settings.ProjektAktivSet;
OnPropertyChanged(nameof(DayTimes));
} catch (Exception) {
//Ein Tag ohne Einträge gibt eine Fehlermeldung,
//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
// LoadSettingsAsync();
var settings = await _hoursService.GetSettingsAsync();
UpdateSettings(settings);
} finally {
DayTime = new DayTime();
DayTime.Day = _date;
DayTime.EmployeeId = GlobalVar.EmployeeId;
DayTime.EmployeeId = _settings.EmployeeId;
DayTime.GemeindeAktiv = new Gemeinde();
DayTime.ProjektAktiv = new Projekt();
DayTime.FreistellungAktiv = new Freistellung();

View File

@@ -15,6 +15,7 @@ namespace Jugenddienst_Stunden.ViewModels;
/// </summary>
public partial class StundenViewModel : ObservableObject, IQueryAttributable, INotifyPropertyChanged {
private readonly IHoursService _hoursService;
private readonly IAppSettings _settings;
public ICommand NewEntryCommand { get; }
public ICommand SelectEntryCommand { get; }
@@ -50,7 +51,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// </summary>
[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;
@@ -82,14 +83,10 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
LoadOverview = "Lade Summen für " + dateToday.ToString("MMMM yy");
// Task.Run(() => LoadDay(value));
// NICHT Task.Run: LoadDay aktualisiert UI-gebundene Properties
MainThread.BeginInvokeOnMainThread(async () =>
{
try
{
MainThread.BeginInvokeOnMainThread(async () => {
try {
await LoadDay(dateToday);
}
catch (Exception ex)
{
} catch (Exception ex) {
AlertEvent?.Invoke(this, ex.Message);
}
});
@@ -101,14 +98,14 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// Monatsübersicht: Geleistete Stunden
/// </summary>
public double? ZeitCalculated {
get => Hours.Zeit_total;
get => Hours.zeit_total;
}
/// <summary>
/// Monatsübersicht: Sollstunden
/// </summary>
public double? Nominal {
get => Hours.Nominal;
get => Hours.nominal;
}
/// <summary>
@@ -136,7 +133,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary>
/// Monatsübersicht: Resturlaub
/// </summary>
public double Holiday {
public double? Holiday {
get => Hours.holiday;
}
@@ -162,8 +159,9 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary>
/// CTOR (DI)
/// </summary>
public StundenViewModel(IHoursService hoursService) {
public StundenViewModel(IHoursService hoursService, IAppSettings appSettings) {
_hoursService = hoursService;
_settings = appSettings;
Hours = new Hours();
LoadOverview = "Lade Summen für " + DateToday.ToString("MMMM");
@@ -174,20 +172,6 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
RefreshListCommand = new AsyncRelayCommand(RefreshList);
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)");
}
//_hour = await HoursBase.LoadData();
RefreshProperties();
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
@@ -244,16 +227,14 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// </summary>
public async Task LoadDay(DateTime date) {
// kleine Initialwerte sind ok, aber UI-Thread sicher setzen:
await MainThread.InvokeOnMainThreadAsync(() =>
{
await MainThread.InvokeOnMainThreadAsync(() => {
DayTotal = new TimeOnly(0);
Sollstunden = new TimeOnly(0);
});
try {
var (dayTimes, settings) = await _hoursService.GetDayWithSettingsAsync(date);
await MainThread.InvokeOnMainThreadAsync(() =>
{
await MainThread.InvokeOnMainThreadAsync(() => {
DayTimes = dayTimes;
Settings = settings;
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();
if (_soll.Count > 0)
{
if (_soll.Count > 0) {
var soll = TimeOnly.FromTimeSpan(TimeSpan.FromHours(_soll[0].Zeit));
await MainThread.InvokeOnMainThreadAsync(() => Sollstunden = soll);
}
@@ -287,9 +267,8 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
//Nach der Tagessumme die anderen Tage anhängen
if (DayTimes != null) {
var more = await _hoursService.GetDayRangeAsync(date, date.AddDays(3));
if (more != null && more.Count > 0)
{
var more = await _hoursService.GetDayRangeAsync(date.AddDays(1), date.AddDays(3));
if (more != null && more.Count > 0) {
await MainThread.InvokeOnMainThreadAsync(() =>
DayTimes = DayTimes.Concat(more).ToList()
);
@@ -297,8 +276,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
}
} catch (Exception e) {
await MainThread.InvokeOnMainThreadAsync(() =>
{
await MainThread.InvokeOnMainThreadAsync(() => {
DayTimes = new List<DayTime>();
//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 {
await MainThread.InvokeOnMainThreadAsync(() =>
{
await MainThread.InvokeOnMainThreadAsync(() => {
OnPropertyChanged(nameof(DayTotal));
OnPropertyChanged(nameof(Sollstunden));
OnPropertyChanged(nameof(DateToday));
@@ -345,7 +322,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary>
/// Refreshes all properties
/// </summary>
private void RefreshProperties() {
public void RefreshProperties() {
OnPropertyChanged(nameof(Hours));
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Nominal));

View File

@@ -1,3 +1,4 @@
using Jugenddienst_Stunden.Interfaces;
using Jugenddienst_Stunden.Models;
using Jugenddienst_Stunden.Types;
using Jugenddienst_Stunden.ViewModels;
@@ -10,9 +11,6 @@ namespace Jugenddienst_Stunden.Views;
/// Die Loginseite mit dem Barcodescanner
/// </summary>
public partial class LoginPage : ContentPage {
private DateTime _lastDetectionTime;
private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5);
/// <summary>
/// CTOR
@@ -33,7 +31,14 @@ public partial class LoginPage : ContentPage {
if (BindingContext is LoginViewModel vm) {
vm.AlertEvent += async (_, msg) => await DisplayAlert("Fehler:", msg, "OK");
vm.InfoEvent += async (_, msg) => await DisplayAlert("Information:", msg, "OK");
//vm.InfoEvent += async (_, msg) => await DisplayAlert("Information:", msg, "OK");
// Neues InfoEvent: Dialog anzeigen und nach Bestätigung das Result setzen
vm.InfoEvent += async (_, infoArgs) => {
await MainThread.InvokeOnMainThreadAsync(async () => {
await DisplayAlert(infoArgs.Title, infoArgs.Message, infoArgs.ConfirmText);
infoArgs.SetResult(true);
});
};
}
barcodeScannerView.Options =
@@ -52,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() {
base.OnDisappearing();
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() {
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;
}
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"
Title="{Binding Title}">
<ContentPage.BindingContext>
<models:StundeViewModel />
</ContentPage.BindingContext>
<ContentPage.Resources>
<ResourceDictionary>
<conv:IntBoolConverter x:Key="IntBoolConverter" />
@@ -25,11 +21,24 @@
StatusBarStyle="LightContent" />
</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>
<Label Text="{Binding SubTitle}" FontSize="Medium" FontAttributes="Bold" Margin="4,0,0,0" Grid.Row="0" />
<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" />
@@ -37,7 +46,7 @@
</OnPlatform>
</Border.Padding>
<FlexLayout Direction="Row" AlignItems="Start" Wrap="Wrap" JustifyContent="SpaceBetween">
<FlexLayout Direction="Row" AlignItems="Start" Wrap="Wrap" JustifyContent="Start" AlignContent="Start">
<HorizontalStackLayout Spacing="10">
<Label Text="Beginn" VerticalTextAlignment="Center" HorizontalTextAlignment="End"
MinimumWidthRequest="60">
@@ -56,14 +65,16 @@
</FlexLayout>
</Border>
<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>
<HorizontalStackLayout Spacing="10">
<Picker x:Name="pick_gemeinde" Title="Gemeinde" ItemsSource="{Binding OptionsGemeinde}"
SelectedItem="{Binding DayTime.GemeindeAktiv, Mode=TwoWay}" ItemDisplayBinding="{Binding Name}"
IsVisible="{Binding GemeindeAktivSet}">
@@ -78,11 +89,13 @@
</Picker>
</HorizontalStackLayout>
</Border>
</FlexLayout>
<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"
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}"
Command="{Binding SaveCommand}" />
@@ -95,14 +108,14 @@
</Grid>
<BoxView HeightRequest="1" Margin="3,10" />
<BoxView HeightRequest="1" Margin="3,10" Grid.Row="4" />
<Label Text="Noch keine Einträge vorhanden"
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"
IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}">
IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}" Grid.Row="5">
<Label>
<Label.FormattedText>
<FormattedString>
@@ -113,14 +126,16 @@
</Label>
</StackLayout>
<ScrollView IsVisible="{Binding DayTimes, Converter={StaticResource CollectionVisibilityConverter}}">
<CollectionView
ItemsSource="{Binding DayTimes}"
x:Name="stundeItems" Margin="0"
HeightRequest="350"
SelectionMode="Single"
VerticalOptions="Start"
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>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="0" />
@@ -155,6 +170,8 @@
</CollectionView.ItemTemplate>
</CollectionView>
</ScrollView>
</VerticalStackLayout>
</Grid>
</ContentPage>

View File

@@ -13,15 +13,14 @@ public partial class StundePage : ContentPage {
/// <summary>
/// CTOR
/// </summary>
public StundePage() {
public StundePage(StundeViewModel vm) {
InitializeComponent();
if (BindingContext is StundeViewModel vm) {
BindingContext = vm;
vm.AlertEvent += Vm_AlertEvent;
vm.InfoEvent += Vm_InfoEvent;
vm.ConfirmEvent += ShowConfirm;
}
}
private void Vm_AlertEvent(object? sender, string e) {
DisplayAlert("Fehler:", e, "OK");
@@ -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>
<ResourceDictionary>
<conv:SecondsTimeConverter x:Key="secToTime" />
<conv:AnyTrueMultiConverter x:Key="AnyTrue"/>
<FontImageSource x:Key="ToolbarIcon"
Glyph="+"
Size="22"
@@ -27,7 +28,7 @@
</ContentPage.Behaviors>
<ContentPage.ToolbarItems>
<!--<ToolbarItem Text="Lade Liste" Command="{Binding RefreshListCommand}"/>-->
<ToolbarItem Text="Neuer Eintrag" IconImageSource="{StaticResource ToolbarIcon}"
Command="{Binding NewEntryCommand}" />
</ContentPage.ToolbarItems>
@@ -35,7 +36,6 @@
<RefreshView x:Name="MyRefreshView" Command="{Binding RefreshCommand}" IsRefreshing="{Binding IsRefreshing}"
Margin="10" Padding="10">
<Grid RowDefinitions="50,*,Auto,80">
<!--<VerticalStackLayout Spacing="10" Margin="10">-->
<Grid RowDefinitions="Auto" ColumnDefinitions="Auto,*" HeightRequest="50" Grid.Row="0">
<DatePicker Grid.Column="0" MinimumDate="{Binding MinimumDate}"
@@ -53,7 +53,6 @@
<CollectionView
ItemsSource="{Binding DayTimes}"
x:Name="stundeItems" Margin="0,0,0,20"
SelectionMode="Single"
SelectionChangedCommand="{Binding SelectEntryCommand}"
SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}"
@@ -61,12 +60,18 @@
Grid.Row="1">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="0" />
<LinearItemsLayout Orientation="Vertical" ItemSpacing="10" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<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>
<VisualStateGroup Name="CommonStates">
@@ -85,15 +90,9 @@
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*" >
<HorizontalStackLayout>
<HorizontalStackLayout.Triggers>
<DataTrigger TargetType="HorizontalStackLayout" Binding="{Binding Approved}"
Value="True">
<Setter Property="BackgroundColor" Value="LightCoral" />
<Setter Property="Padding" Value="4" />
</DataTrigger>
</HorizontalStackLayout.Triggers>
<HorizontalStackLayout Grid.Row="0" >
<Label Text="{Binding Day, StringFormat='{0:dddd, dd. MMMM}'}" />
<Label Text="von" Padding="5,0,5,0" />
<Label Text="{Binding Begin}" />
@@ -101,39 +100,43 @@
<Label Text="{Binding End}" />
</HorizontalStackLayout>
<HorizontalStackLayout HorizontalOptions="FillAndExpand">
<HorizontalStackLayout.Triggers>
<DataTrigger TargetType="HorizontalStackLayout" Binding="{Binding Approved}"
Value="True">
<Setter Property="BackgroundColor" Value="LightCoral" />
<Setter Property="Padding" Value="4" />
</DataTrigger>
</HorizontalStackLayout.Triggers>
<Label Text="{Binding GemeindeAktiv.Name}" Margin="0,0,10,0"
<HorizontalStackLayout Grid.Row="1" >
<!--<HorizontalStackLayout.IsVisible>
<MultiBinding Converter="{StaticResource AnyTrue}">
<Binding Path="Approved"/>
<Binding Path="BindingContext.GemeindeAktivSet" Source="{RelativeSource AncestorType={x:Type ContentPage}}"/>
<Binding Path="BindingContext.ProjektAktivSet" Source="{RelativeSource AncestorType={x:Type ContentPage}}"/>
</MultiBinding>
</HorizontalStackLayout.IsVisible>-->
<Label Text="{Binding GemeindeAktiv.Name}" Padding="0,0,10,0"
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}" />
<Label Text="{Binding FreistellungAktiv.Name}" IsVisible="{Binding Approved}" />
</HorizontalStackLayout>
<Label Text="{Binding Description}" Padding="0,0,0,15" />
</VerticalStackLayout>
<!-- Ensure description row does not paint background -->
<Label Text="{Binding Description}" Grid.Row="2" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<!--<BoxView HeightRequest="1" Grid.Row="2" Margin="0,5,0,15" />-->
<Button Text="{Binding LoadOverview}"
Grid.Row="2"
Command="{Binding LoadDataCommand}"
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" />
<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="1" Text="Summe:" />
<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="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" />
@@ -156,7 +159,7 @@
</Border>
</Grid>
<!--</VerticalStackLayout>-->
</RefreshView>
</ContentPage>

View File

@@ -28,24 +28,13 @@ public partial class StundenPage : ContentPage {
vm.AlertEvent += Vm_AlertEvent;
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) {
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) {
MainThread.BeginInvokeOnMainThread(async () => {
CancellationTokenSource cts = new CancellationTokenSource();
@@ -69,6 +58,12 @@ public partial class StundenPage : ContentPage {
} catch (Exception ex) {
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", "") != "";
}
// private async void NavigateToTargetPage() {
// await Navigation.PushAsync(new LoginPage());
// }
private Task NavigateToTargetPage() {
// Shell-Navigation statt Navigation.PushAsync