Refactor LoginPage to MVVM

This commit is contained in:
2025-12-17 17:25:42 +01:00
parent bb5aac2944
commit c11b361655
11 changed files with 442 additions and 134 deletions

View File

@@ -0,0 +1,8 @@
using System.Globalization;
namespace Jugenddienst_Stunden.Converter;
public sealed class EventArgsPassThroughConverter : IValueConverter {
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
}

View File

@@ -0,0 +1,15 @@
using System.Globalization;
namespace Jugenddienst_Stunden.Converter;
public sealed class InverseBoolConverter : IValueConverter {
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) {
if (value is bool b) return !b;
return true;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
if (value is bool b) return !b;
return false;
}
}

View File

@@ -1,8 +1,9 @@
using Jugenddienst_Stunden.Interfaces;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Net.Http.Json;
using Jugenddienst_Stunden.Interfaces;
using ZXing.Aztec.Internal;
namespace Jugenddienst_Stunden.Infrastructure;
@@ -12,51 +13,68 @@ internal sealed class ApiClient : IApiClient {
private readonly ApiOptions _options;
private readonly IAppSettings _settings;
public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) {
_http = http;
_options = options;
_settings = settings;
public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) {
_http = http;
_options = options;
_settings = settings;
_http.Timeout = options.Timeout;
if (!_http.DefaultRequestHeaders.Accept.Any())
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Timeout nur einmalig beim Erstellen setzen spätere Änderungen an HttpClient.Timeout
// nach der ersten Verwendung führen zu InvalidOperationException.
if (_http.Timeout != options.Timeout)
_http.Timeout = options.Timeout;
// Standardmäßig JSON akzeptieren; doppelte Einträge vermeiden
if (!_http.DefaultRequestHeaders.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true))
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = tokenProvider.GetToken();
if (!string.IsNullOrWhiteSpace(token))
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// KEINE globalen Header/Properties mehr dynamisch setzen. Authorization wird pro Request gesetzt.
_json = new JsonSerializerOptions {
PropertyNameCaseInsensitive = true,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
_json = new JsonSerializerOptions {
PropertyNameCaseInsensitive = true,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
// Globale Converter: erlauben numerische Felder auch als Strings (z.B. user.id)
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleInt32Converter());
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter());
// Stelle sicher, dass die BaseAddress sofort aus den aktuellen Settings (Preferences) gesetzt wird
// und nicht erst beim ersten Request. Dadurch steht die ApiUrl ab Initialisierung zur Verfügung.
EnsureBaseAddress();
}
// WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern das verursacht Exceptions,
// sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request.
}
public Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
=> SendAsync<T>(HttpMethod.Get, path, null, query, ct);
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
IDictionary<string, string?>? query = null, CancellationToken ct = default) {
// Vor jedem Request sicherstellen, dass die (ggf. geänderte) BaseAddress gesetzt ist
EnsureBaseAddress();
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(method, uri) {
Content = body is null ? null : JsonContent.Create(body, options: _json)
};
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.
var currentToken = _settings.ApiKey;
if (!string.IsNullOrWhiteSpace(currentToken)) {
// Vorherige Header (falls vorhanden) entfernen, um Duplikate zu vermeiden
req.Headers.Remove("Authorization");
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {currentToken}");
}
if (body is HttpContent httpContent) {
req.Content = httpContent;
} else if (body is not null) {
req.Content = JsonContent.Create(body, options: _json);
}
// Sicherstellen, dass Accept: application/json auch auf Request-Ebene vorhanden ist (z. B. für LoginWithToken GET)
if (!req.Headers.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) {
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
//if (!res.IsSuccessStatusCode)
// throw ApiException.From(res.StatusCode, TryGetMessage(text), text);
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.
@@ -91,22 +109,7 @@ internal sealed class ApiClient : IApiClient {
public Task DeleteAsync(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
=> SendAsync<object>(HttpMethod.Delete, path, null, query, ct);
private void EnsureBaseAddress() {
var baseUrl = _settings.ApiUrl;
if (string.IsNullOrWhiteSpace(baseUrl)) {
throw new InvalidOperationException(
"ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl'), " +
"z.B. im Login/Setup, bevor API-Aufrufe stattfinden."
);
}
// nur setzen, wenn nötig (damit spätere Änderungen nach Login greifen)
if (_http.BaseAddress is null || !Uri.Equals(_http.BaseAddress, new Uri(baseUrl, UriKind.Absolute))) {
_http.BaseAddress = new Uri(baseUrl, UriKind.Absolute);
_http.Timeout = _options.Timeout;
}
}
// Entfernt: EnsureBaseAddress wir ändern BaseAddress nicht mehr dynamisch.
private static string TryGetMessage(string text) {
try {
@@ -119,15 +122,29 @@ internal sealed class ApiClient : IApiClient {
return text;
}
private static Uri BuildUri(string path, IDictionary<string, string?>? query) {
if (query is null || query.Count == 0)
return new Uri(path, UriKind.Relative);
private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary<string, string?>? query) {
if (string.IsNullOrWhiteSpace(baseUrl))
throw new InvalidOperationException(
"ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl').");
var sb = new StringBuilder(path);
sb.Append(path.Contains('?') ? '&' : '?');
sb.Append(string.Join('&', query
.Where(kv => kv.Value is not null)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
return new Uri(sb.ToString(), UriKind.Relative);
}
// Basis muss absolut sein (z. B. https://host/appapi/)
var baseUri = new Uri(baseUrl, UriKind.Absolute);
// Pfad relativ zur Basis aufbauen
string relativePath = path ?? string.Empty;
if (query is not null && query.Count > 0) {
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();
}
// Wenn path bereits absolut ist, direkt verwenden
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
return absoluteFromPath;
return new Uri(baseUri, relativePath);
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Jugenddienst_Stunden.Infrastructure;
internal sealed class RequestLoggingHandler : DelegatingHandler {
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
// Log outgoing request URI + headers
Debug.WriteLine($"[Http] Request: {request.Method} {request.RequestUri}");
foreach (var h in request.Headers) {
Debug.WriteLine($"[Http] RequestHeader: {h.Key} = {string.Join(", ", h.Value)}");
}
if (request.Content is not null) {
foreach (var h in request.Content.Headers) {
Debug.WriteLine($"[Http] ContentHeader: {h.Key} = {string.Join(", ", h.Value)}");
}
}
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
// Log response status + Location (bei Redirects) + final request URI used by handler
Debug.WriteLine($"[Http] Response: {(int)response.StatusCode} {response.ReasonPhrase}");
if (response.Headers.Location is not null)
Debug.WriteLine($"[Http] Response Location: {response.Headers.Location}");
if (response.RequestMessage?.RequestUri is not null)
Debug.WriteLine($"[Http] Final RequestUri: {response.RequestMessage.RequestUri}");
return response;
}
}

View File

@@ -0,0 +1,8 @@
namespace Jugenddienst_Stunden.Interfaces;
using Jugenddienst_Stunden.Types;
public interface IAuthService {
Task<User> LoginWithCredentials(string username, string password, string serverUrl, CancellationToken ct = default);
Task<User> LoginWithToken(string token, CancellationToken ct = default);
}

View File

@@ -8,6 +8,7 @@ using Jugenddienst_Stunden.Validators;
using Microsoft.Extensions.Logging;
using ZXing.Net.Maui.Controls;
using System.Net.Http;
using Jugenddienst_Stunden.ViewModels;
namespace Jugenddienst_Stunden;
@@ -56,7 +57,16 @@ public static class MauiProgram {
builder.Services.AddSingleton<ITokenProvider, SettingsTokenProvider>();
// HttpClient + ApiClient
builder.Services.AddSingleton<HttpClient>(_ => new HttpClient());
// Configure HttpClient with RequestLoggingHandler and disable automatic redirects for diagnosis
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 {
@@ -85,13 +95,16 @@ public static class MauiProgram {
// DI: Validatoren
builder.Services.AddSingleton<IHoursValidator, HoursValidator>();
// DI: Services & Repositories
builder.Services.AddSingleton<IHoursRepository, HoursRepository>();
builder.Services.AddSingleton<IHoursService, HoursService>();
// 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>();
// DI: Views/ViewModels
builder.Services.AddTransient<ViewModels.StundenViewModel>();
builder.Services.AddTransient<Views.StundenPage>();
builder.Services.AddTransient<ViewModels.LoginViewModel>();
builder.Services.AddTransient<Views.LoginPage>();
return builder.Build();
}

View File

@@ -0,0 +1,94 @@
using System.Net.Http;
using System.Text;
using Jugenddienst_Stunden.Infrastructure;
using Jugenddienst_Stunden.Interfaces;
using Jugenddienst_Stunden.Models;
using Jugenddienst_Stunden.Types;
namespace Jugenddienst_Stunden.Services;
internal sealed class AuthService : IAuthService {
private readonly IApiClient _api;
private readonly IAppSettings _settings;
private readonly IAlertService _alerts;
public AuthService(IApiClient api, IAppSettings settings, IAlertService alerts) {
_api = api;
_settings = settings;
_alerts = alerts;
}
public async Task<User> LoginWithCredentials(string username, string password, string serverUrl, CancellationToken ct = default) {
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new Exception("Benutzername und Passwort werden benötigt.");
var apiBase = NormalizeApiUrl(serverUrl);
_settings.ApiUrl = apiBase; // BaseAddress für IApiClient setzen
var content = new FormUrlEncodedContent(new[] {
new KeyValuePair<string, string>("user", username),
new KeyValuePair<string, string>("pass", password)
});
// POST ohne Pfad die API erwartet /appapi
// Wichtig: Basis-URL hat garantiert einen abschließenden Slash (…/appapi/),
// sodass ein leerer Pfad nicht zu Redirects führt (die den POST in GET verwandeln könnten).
var res = await _api.SendAsync<BaseResponse>(HttpMethod.Post, string.Empty, content, null, ct).ConfigureAwait(false);
if (res.user is null)
throw new Exception(res.message ?? "Ungültige Antwort vom Server.");
ApplyUser(res.user, apiBase);
return res.user;
}
public async Task<User> LoginWithToken(string token, CancellationToken ct = default) {
if (string.IsNullOrWhiteSpace(token)) throw new Exception("Kein Token erkannt.");
// QR-Token enthält die URL extrahiere sie
var td = new TokenData(token);
// URL aus dem Token ebenfalls normalisieren, damit sie auf "/appapi/" endet
_settings.ApiUrl = NormalizeApiUrl(td.Url);
_settings.ApiKey = token;
var res = await _api.GetAsync<BaseResponse>(string.Empty, null, ct).ConfigureAwait(false);
if (res.user is null)
throw new Exception(res.message ?? "Ungültige Antwort vom Server.");
ApplyUser(res.user, td.Url);
return res.user;
}
private void ApplyUser(User user, string apiBase) {
_settings.ApiUrl = apiBase;
// Wenn der Server keinen Token im User zurückliefert (QR-Login-Fall), bestehenden Token beibehalten
var tokenToUse = string.IsNullOrWhiteSpace(user.Token) ? _settings.ApiKey : user.Token;
_settings.ApiKey = tokenToUse;
_settings.EmployeeId = user.Id;
_settings.Name = user.Name;
_settings.Surname = user.Surname;
}
private static string NormalizeApiUrl(string input) {
if (string.IsNullOrWhiteSpace(input)) throw new Exception("Server-URL wird benötigt.");
var url = input.Trim();
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) {
url = "https://" + url;
}
// Sicherstellen, dass der Pfad auf "/appapi" endet
if (!url.EndsWith("/appapi", StringComparison.OrdinalIgnoreCase)) {
url = url.TrimEnd('/') + "/appapi";
}
// WICHTIG: Einen abschließenden Slash erzwingen, damit relative Pfade korrekt angehängt werden
// und damit POST auf Basis-URL (leerem Pfad) nicht zu einem 301/302-Redirect führt,
// der den Body (user/pass) verlieren könnte.
//if (!url.EndsWith("/", StringComparison.Ordinal)) {
// url += "/";
//}
if (url.EndsWith("/", StringComparison.Ordinal)) {
url = url.Remove(url.Length - 1, 1);
}
return url;
}
}

View File

@@ -1,6 +1,6 @@
namespace Jugenddienst_Stunden.Types;
internal class User {
public class User {
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }

View File

@@ -1,33 +1,145 @@
namespace Jugenddienst_Stunden.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Jugenddienst_Stunden.Interfaces;
namespace Jugenddienst_Stunden.ViewModels;
/// <summary>
/// Die Loginseite
/// ViewModel für die Loginseite (MVVM)
/// </summary>
public class LoginViewModel {
/// <summary>
/// Name der Anwendung
/// </summary>
public string AppTitle => AppInfo.Name;
public partial class LoginViewModel : ObservableObject {
private readonly IAuthService _auth;
private readonly IAppSettings _settings;
private readonly IAlertService? _alerts;
private DateTime _lastDetectionTime = DateTime.MinValue;
private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5);
/// <summary>
/// Programmversion
/// </summary>
public string Version => AppInfo.VersionString;
public event EventHandler<string>? AlertEvent;
public event EventHandler<string>? InfoEvent;
/// <summary>
/// Kurze Mitteilung für den Anwender
/// </summary>
public string Message { get; set; } = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite.";
/// <summary>
/// Name der Anwendung
/// </summary>
public string AppTitle => AppInfo.Name;
/// <summary>
/// Genutzer Server für die API
/// </summary>
public string Server { get; set; } = "Server: " + Preferences.Default.Get("apiUrl", "").Replace("/appapi", "")
.Replace("https://", "").Replace("http://", "");
/// <summary>
/// Programmversion
/// </summary>
public string Version => AppInfo.VersionString;
/// <summary>
/// Titel der Seite - im Moment der aktuelle Anwender
/// </summary>
public string Title { get; set; } = Preferences.Default.Get("name", "Nicht") + " " +
Preferences.Default.Get("surname", "eingeloggt");
[ObservableProperty]
private string message = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite.";
[ObservableProperty]
private string? server;
[ObservableProperty]
private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt");
[ObservableProperty]
private string? username;
[ObservableProperty]
private string? password;
[ObservableProperty]
private bool isManualMode;
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private bool isDetecting;
// Explizite Command-Property für den QR-Scanner-Event, damit das Binding in XAML zuverlässig greift
public IAsyncRelayCommand<object?> QrDetectedCommand { get; }
public LoginViewModel(IAuthService auth, IAppSettings settings) {
_auth = auth;
_settings = settings;
// gespeicherte Präferenz für Logintyp laden
var lt = Preferences.Default.Get("logintype", "qr");
isManualMode = string.Equals(lt, "manual", StringComparison.OrdinalIgnoreCase);
// Scanner standardmäßig nur im QR-Modus aktivieren
IsDetecting = !isManualMode;
// Serveranzeige vorbereiten
var apiUrl = Preferences.Default.Get("apiUrl", string.Empty);
if (!string.IsNullOrWhiteSpace(apiUrl)) {
Server = "Server: " + apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", "");
}
// 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) {
_alerts = alertService;
if (alertService is not null) {
alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg);
}
}
partial void OnIsManualModeChanged(bool value) {
Preferences.Default.Set("logintype", value ? "manual" : "qr");
// Scanner nur aktiv, wenn QR-Modus aktiv ist
IsDetecting = !value;
}
[RelayCommand]
private async Task LoginAsync() {
if (IsBusy) return;
try {
IsBusy = true;
var user = await _auth.LoginWithCredentials(Username?.Trim() ?? string.Empty,
Password ?? string.Empty,
(Server ?? string.Empty).Replace("Server:", string.Empty).Trim());
Title = $"{user.Name} {user.Surname}";
InfoEvent?.Invoke(this, "Login erfolgreich");
await Shell.Current.GoToAsync("//StundenPage");
} catch (Exception ex) {
if (_alerts is not null) {
_alerts.Raise(ex.Message);
} else {
AlertEvent?.Invoke(this, ex.Message);
}
} finally {
IsBusy = false;
}
}
private async Task QrDetectedAsync(object? args) {
var now = DateTime.Now;
if ((now - _lastDetectionTime) <= _detectionInterval) return;
_lastDetectionTime = now;
try {
var token = ExtractFirstBarcodeValue(args);
if (string.IsNullOrWhiteSpace(token)) return;
var user = await _auth.LoginWithToken(token);
Title = $"{user.Name} {user.Surname}";
await Shell.Current.GoToAsync("//StundenPage");
} catch (Exception ex) {
if (_alerts is not null) {
_alerts.Raise(ex.Message);
} else {
AlertEvent?.Invoke(this, ex.Message);
}
}
}
private static string? ExtractFirstBarcodeValue(object? args) {
try {
if (args is ZXing.Net.Maui.BarcodeDetectionEventArgs e && e.Results is not null) {
return e.Results.FirstOrDefault()?.Value;
}
} catch { }
return null;
}
}

View File

@@ -10,13 +10,13 @@
Title="{Binding Title}">
<ContentPage.BindingContext>
<models:LoginViewModel />
</ContentPage.BindingContext>
<!-- BindingContext wird via DI im Code-Behind gesetzt -->
<ContentPage.Resources>
<ResourceDictionary>
<conv:StringVisibilityConverter x:Key="StringVisibilityConverter" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<conv:EventArgsPassThroughConverter x:Key="EventArgsPassThroughConverter" />
</ResourceDictionary>
</ContentPage.Resources>
@@ -39,7 +39,7 @@
</HorizontalStackLayout>
<Grid Grid.Column="1" ColumnDefinitions="*,50" ColumnSpacing="10">
<Label Text="Login QR/manuell" VerticalOptions="Center" Grid.Column="0" />
<Switch x:Name="LoginSwitch" IsToggled="False" Toggled="Switch_Toggled" VerticalOptions="Center"
<Switch x:Name="LoginSwitch" IsToggled="{Binding IsManualMode}" VerticalOptions="Center"
Grid.Column="1" />
</Grid>
</Grid>
@@ -47,25 +47,30 @@
<Label x:Name="ServerLabel" Text="{Binding Server}"
IsVisible="{Binding Server, Converter={StaticResource StringVisibilityConverter}}" />
<VerticalStackLayout x:Name="LoginQR" Margin="0,20,0,0">
<VerticalStackLayout x:Name="LoginQR" Margin="0,20,0,0" IsVisible="{Binding IsManualMode, Converter={StaticResource InverseBoolConverter}}">
<Label Text="Login mit QR-Code" FontSize="32" HorizontalOptions="Start" />
<Label x:Name="Message" Text="{Binding Message}" Margin="0,15" />
<Border HeightRequest="300" Padding="0">
<zxing:CameraBarcodeReaderView
x:Name="barcodeScannerView"
BarcodesDetected="BarcodesDetected"
HorizontalOptions="FillAndExpand"
VerticalOptions="FillAndExpand" />
VerticalOptions="FillAndExpand"
IsDetecting="{Binding IsDetecting}">
<zxing:CameraBarcodeReaderView.Behaviors>
<toolkit:EventToCommandBehavior EventName="BarcodesDetected"
Command="{Binding QrDetectedCommand}"
EventArgsConverter="{StaticResource EventArgsPassThroughConverter}" />
</zxing:CameraBarcodeReaderView.Behaviors>
</zxing:CameraBarcodeReaderView>
</Border>
</VerticalStackLayout>
<VerticalStackLayout x:Name="LoginManual" Spacing="25">
<VerticalStackLayout x:Name="LoginManual" Spacing="25" IsVisible="{Binding IsManualMode}">
<Label Text="Manueller Login" FontSize="32" HorizontalOptions="Start" Margin="0, 20, 0, 0" />
<Entry x:Name="UsernameEntry" Placeholder="Benutzername (Mailadresse)" Keyboard="Email" />
<Entry x:Name="PasswordEntry" Placeholder="Passwort" IsPassword="True" />
<Entry x:Name="ServerEntry" Placeholder="Server (gleich wie im Browser)" Keyboard="Url" />
<Button Text="Login" Clicked="OnLoginButtonClicked"
<Entry x:Name="UsernameEntry" Text="{Binding Username}" Placeholder="Benutzername (Mailadresse)" Keyboard="Email" />
<Entry x:Name="PasswordEntry" Text="{Binding Password}" Placeholder="Passwort" IsPassword="True" />
<Entry x:Name="ServerEntry" Text="{Binding Server}" Placeholder="Server (gleich wie im Browser)" Keyboard="Url" />
<Button Text="Login" Command="{Binding LoginCommand}"
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" />
</VerticalStackLayout>
</VerticalStackLayout>

View File

@@ -1,5 +1,6 @@
using Jugenddienst_Stunden.Models;
using Jugenddienst_Stunden.Types;
using Jugenddienst_Stunden.ViewModels;
using ZXing.Net.Maui;
@@ -19,9 +20,38 @@ public partial class LoginPage : ContentPage {
public LoginPage() {
InitializeComponent();
// BindingContext via DI beziehen, falls nicht bereits gesetzt
try {
if (BindingContext is null) {
var sp = Application.Current?.Handler?.MauiContext?.Services
?? throw new InvalidOperationException("DI container ist nicht verfügbar.");
BindingContext = sp.GetRequiredService<LoginViewModel>();
}
} catch (Exception) {
// Ignorieren: Fallback bleibt leerer BindingContext
}
if (BindingContext is LoginViewModel vm) {
vm.AlertEvent += async (_, msg) => await DisplayAlert("Fehler:", msg, "OK");
vm.InfoEvent += async (_, msg) => await DisplayAlert("Information:", msg, "OK");
}
barcodeScannerView.Options =
new BarcodeReaderOptions { Formats = BarcodeFormat.QrCode, AutoRotate = true, Multiple = false };
// Fallback-Verkabelung: Falls das EventToCommandBehavior in XAML nicht greift,
// leiten wir das Kamera-Event manuell an das ViewModel-Command weiter.
barcodeScannerView.BarcodesDetected += (s, e) => {
if (BindingContext is LoginViewModel vm && vm.QrDetectedCommand is not null) {
// Sicherstellen, dass die Command-Ausführung im UI-Thread erfolgt
MainThread.BeginInvokeOnMainThread(async () => {
if (vm.QrDetectedCommand.CanExecute(e)) {
await vm.QrDetectedCommand.ExecuteAsync(e);
}
});
}
};
//if (BindingContext is LoginViewModel vm) {
// vm.AlertEvent += Vm_AlertEvent;
// vm.InfoEvent += Vm_InfoEvent;
@@ -29,20 +59,7 @@ public partial class LoginPage : ContentPage {
//}
//Zwischen manuellem und automatischem Login (mit QR-Code) umschalten und die Schalterstellung merken
bool sqr = true;
bool sma = false;
if (Preferences.Default.Get("logintype", "") == "manual") {
sqr = false;
sma = true;
LoginSwitch.IsToggled = true;
Message.IsVisible = false;
} else {
LoginSwitch.IsToggled = false;
Message.IsVisible = true;
}
LoginQR.IsVisible = sqr;
LoginManual.IsVisible = sma;
// MVVM übernimmt Umschalten über IsManualMode im ViewModel; keine Code-Behind-Umschaltung mehr
}
@@ -102,13 +119,13 @@ public partial class LoginPage : ContentPage {
base.OnDisappearing();
barcodeScannerView.CameraLocation = CameraLocation.Front;
barcodeScannerView.IsDetecting = false;
// IsDetecting wird via Binding vom ViewModel gesteuert
}
protected override void OnAppearing() {
base.OnAppearing();
barcodeScannerView.IsDetecting = true;
// IsDetecting wird via Binding vom ViewModel gesteuert
barcodeScannerView.CameraLocation = CameraLocation.Rear;
}
@@ -175,21 +192,7 @@ public partial class LoginPage : ContentPage {
}
//Zwischen manuellem und automatischem Login (mit QR-Code) umschalten und die Schalterstellung merken
private void Switch_Toggled(object sender, ToggledEventArgs e) {
var switcher = (Switch)sender;
if (switcher.IsToggled) {
LoginQR.IsVisible = false;
LoginManual.IsVisible = true;
Message.IsVisible = false;
Preferences.Default.Set("logintype", "manual");
} else {
LoginQR.IsVisible = true;
LoginManual.IsVisible = false;
Message.IsVisible = true;
Preferences.Default.Set("logintype", "qr");
}
}
// Umschalt-Logik erfolgt über Binding an IsManualMode im ViewModel
//private void Vm_AlertEvent(object? sender, string e) {
// DisplayAlert("Fehler:", e, "OK");