Refactor LoginPage to MVVM
This commit is contained in:
@@ -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;
|
||||
}
|
||||
15
Jugenddienst Stunden/Converter/InverseBoolConverter.cs
Normal file
15
Jugenddienst Stunden/Converter/InverseBoolConverter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 Settings‑BaseUrl 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);
|
||||
}
|
||||
}
|
||||
33
Jugenddienst Stunden/Infrastructure/RequestLoggingHandler.cs
Normal file
33
Jugenddienst Stunden/Infrastructure/RequestLoggingHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
8
Jugenddienst Stunden/Interfaces/IAuthService.cs
Normal file
8
Jugenddienst Stunden/Interfaces/IAuthService.cs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
94
Jugenddienst Stunden/Services/AuthService.cs
Normal file
94
Jugenddienst Stunden/Services/AuthService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user