Compare commits

8 Commits

Author SHA1 Message Date
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
17 changed files with 299 additions and 169 deletions

View File

@@ -73,10 +73,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 };
@@ -142,8 +149,27 @@ internal sealed class ApiClient : IApiClient {
}
// Wenn path bereits absolut ist, direkt verwenden
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
return absoluteFromPath;
//if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
// return absoluteFromPath;
// Sonderfall: Wenn path ein absoluter file:// URI ist, diesen relativ zur Basis behandeln
// Weiß nicht wie file:// zustande kommt, vermutlich wäre das zu verhindern
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var uri)) {
if (uri.Scheme == Uri.UriSchemeFile) {
var normalizedBase = baseUrl.Trim();
if (!normalizedBase.EndsWith('/'))
normalizedBase += "/";
if (relativePath.StartsWith('/'))
relativePath = relativePath.TrimStart('/');
var baseUriNormalized = new Uri(normalizedBase, UriKind.Absolute);
return new Uri(baseUriNormalized, relativePath);
}
return uri;
}
return new Uri(baseUri, relativePath);
}

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<69>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

@@ -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

@@ -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

@@ -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> -->
@@ -176,76 +176,76 @@
</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>
<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 +256,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,7 @@ using Microsoft.Extensions.Logging;
using ZXing.Net.Maui.Controls;
using System.Net.Http;
using Jugenddienst_Stunden.ViewModels;
using System.Net;
namespace Jugenddienst_Stunden;
@@ -32,7 +33,7 @@ public static class MauiProgram {
//#if DEBUG
// if (string.IsNullOrWhiteSpace(GlobalVar.ApiKey)) {
// GlobalVar.ApiKey = Preferences.Default.Get("apiKey",
// "MTQxfHNkdFptQkNZTXlPT3ZyMHNBZDl0UnVxNExMRXxodHRwOi8vaG91cnMuZGF1bmkubWluZS5udTo4MS9hcHBhcGk=");
// "MTQxfHNkdFptQkNZTXlPT3ZyMHxodHRwOi8vaG91cnMuZGF1bmkubWluZS5udTo4MS9hcHBhcGk=");
// GlobalVar.Name = Preferences.Default.Get("name", "Testserver: Isabell");
// GlobalVar.Surname = Preferences.Default.Get("surname", "Biasi");
// GlobalVar.EmployeeId = Preferences.Default.Get("EmployeeId", 141);
@@ -42,6 +43,10 @@ public static class MauiProgram {
// builder.Logging.AddDebug();
//#endif
// ApiClient registrieren: SocketsHttpHandler als Primary Handler (vermeidet AndroidMessageHandler-Castfehler)
//var apiOptions = new Infrastructure.ApiOptions { BaseUrl = GlobalVar.ApiUrl, Timeout = TimeSpan.FromSeconds(15) };
//builder.Services.AddApiHttpClient(apiOptions);
// DI: AlertService für globale Alerts (z. B. leere ApiUrl)
builder.Services.AddSingleton<IAlertService, AlertService>();
@@ -50,17 +55,23 @@ public static class MauiProgram {
// 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
// Configure HttpClient with SocketsHttpHandler (managed) and RequestLoggingHandler
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services.AddSingleton<HttpClient>(sp => {
var nativeHandler = new HttpClientHandler { AllowAutoRedirect = false };
var nativeHandler = new SocketsHttpHandler {
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
ConnectTimeout = TimeSpan.FromSeconds(10)
};
var logging = sp.GetRequiredService<RequestLoggingHandler>();
logging.InnerHandler = nativeHandler;
// HttpClient.Timeout will be adjusted by ApiClient if needed
@@ -95,16 +106,18 @@ public static class MauiProgram {
// DI: Validatoren
builder.Services.AddSingleton<IHoursValidator, HoursValidator>();
// DI: Services & Repositories
builder.Services.AddSingleton<IHoursRepository, HoursRepository>();
builder.Services.AddSingleton<IHoursService, HoursService>();
builder.Services.AddSingleton<IAuthService, AuthService>();
// DI: Services & Repositories
builder.Services.AddSingleton<IHoursRepository, HoursRepository>();
builder.Services.AddSingleton<IHoursService, HoursService>();
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>();
// DI: Views/ViewModels
builder.Services.AddTransient<ViewModels.StundenViewModel>();
builder.Services.AddTransient<Views.StundenPage>();
builder.Services.AddTransient<ViewModels.StundeViewModel>();
builder.Services.AddTransient<Views.StundePage>();
builder.Services.AddTransient<ViewModels.LoginViewModel>();
builder.Services.AddTransient<Views.LoginPage>();
return builder.Build();
}

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

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;
@@ -15,7 +16,8 @@ public partial class LoginViewModel : ObservableObject {
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
@@ -102,9 +104,13 @@ public partial class LoginViewModel : ObservableObject {
(Server ?? string.Empty).Trim());
Title = $"{user.Name} {user.Surname}";
InfoEvent?.Invoke(this, "Login erfolgreich");
await Shell.Current.GoToAsync("//StundenPage");
// 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 +134,13 @@ public partial class LoginViewModel : ObservableObject {
var user = await _auth.LoginWithToken(token);
Title = $"{user.Name} {user.Surname}";
await Shell.Current.GoToAsync("//StundenPage");
// 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

@@ -77,33 +77,16 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
//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) {
_hoursService = hoursService;
SaveCommand = new AsyncRelayCommand(Save);
//DeleteCommand = new AsyncRelayCommand(Delete);
DeleteConfirmCommand = new Command(async () => await DeleteConfirm());
}
// 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);
}
//LoadSettingsAsync();
}
private async void LoadSettingsAsync() {
@@ -122,6 +105,16 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
}
}
private async void UpdateSettingsAsync(Settings settings) {
GlobalVar.Settings = settings;
OptionsGemeinde = settings.Gemeinden;
OptionsProjekt = settings.Projekte;
OptionsFreistellung = settings.Freistellungen;
GemeindeAktivSet = settings.GemeindeAktivSet;
ProjektAktivSet = settings.ProjektAktivSet;
}
async Task Save() {
bool exceptionOccurred = false;
bool proceed = true;
@@ -133,15 +126,21 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
}
//Projekt ist ein Pflichtfeld
if (Settings.ProjektAktivSet && DayTime.ProjektAktiv.Id == 0) {
proceed = false;
AlertEvent?.Invoke(this, "Projekt darf nicht leer sein");
if (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) {
proceed = false;
AlertEvent?.Invoke(this, "Gemeinde darf nicht leer sein");
if (Settings.GemeindeAktivSet) {
var gemeindeId = DayTime.GemeindeAktiv?.Id ?? 0;
if (gemeindeId == 0) {
proceed = false;
AlertEvent?.Invoke(this, "Gemeinde darf nicht leer sein");
}
}
if (proceed) {
@@ -179,8 +178,12 @@ public partial class StundeViewModel : ObservableObject, IQueryAttributable {
await ConfirmEvent.Invoke("Achtung", "Löschen kann nicht ungeschehen gemacht werden. Fortfahren?");
if (answer) {
//Löschen
await _hoursService.DeleteEntryAsync(DayTime);
await Shell.Current.GoToAsync($"..?date={DayTime.Day.ToString("yyyy-MM-dd")}");
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
}
@@ -196,46 +199,43 @@ 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"]));
UpdateSettingsAsync(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();
new Gemeinde();
DayTime.ProjektAktiv = OptionsProjekt.FirstOrDefault(Projekt => Projekt.Id == DayTime.Projekt) ??
new Projekt();
new Projekt();
DayTime.FreistellungAktiv =
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));
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));
} catch (Exception e) {
AlertEvent?.Invoke(this, e.Message);
} finally {
//Evtl. noch die anderen Zeiten des gleichen Tages holen
//var day = await _hoursService.GetDayWithSettingsAsync(DayTime.Day);
//DayTimes = day.dayTimes;
//OnPropertyChanged(nameof(DayTimes));
}
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));
} else if (query.ContainsKey("date")) {
Title = "Neuer Eintrag";
@@ -246,15 +246,9 @@ 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;
UpdateSettingsAsync(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

View File

@@ -101,14 +101,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 +136,7 @@ public partial class StundenViewModel : ObservableObject, IQueryAttributable, IN
/// <summary>
/// Monatsübersicht: Resturlaub
/// </summary>
public double Holiday {
public double? Holiday {
get => Hours.holiday;
}
@@ -287,7 +287,7 @@ 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));
var more = await _hoursService.GetDayRangeAsync(date.AddDays(1), date.AddDays(3));
if (more != null && more.Count > 0)
{
await MainThread.InvokeOnMainThreadAsync(() =>

View File

@@ -33,7 +33,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 =

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" />

View File

@@ -13,14 +13,13 @@ public partial class StundePage : ContentPage {
/// <summary>
/// CTOR
/// </summary>
public StundePage() {
public StundePage(StundeViewModel vm) {
InitializeComponent();
if (BindingContext is StundeViewModel vm) {
vm.AlertEvent += Vm_AlertEvent;
vm.InfoEvent += Vm_InfoEvent;
vm.ConfirmEvent += ShowConfirm;
}
BindingContext = vm;
vm.AlertEvent += Vm_AlertEvent;
vm.InfoEvent += Vm_InfoEvent;
vm.ConfirmEvent += ShowConfirm;
}
private void Vm_AlertEvent(object? sender, string e) {