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.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Net.Http.Json;
|
using ZXing.Aztec.Internal;
|
||||||
using Jugenddienst_Stunden.Interfaces;
|
|
||||||
|
|
||||||
namespace Jugenddienst_Stunden.Infrastructure;
|
namespace Jugenddienst_Stunden.Infrastructure;
|
||||||
|
|
||||||
@@ -17,13 +18,15 @@ internal sealed class ApiClient : IApiClient {
|
|||||||
_options = options;
|
_options = options;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
|
||||||
|
// 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;
|
_http.Timeout = options.Timeout;
|
||||||
if (!_http.DefaultRequestHeaders.Accept.Any())
|
// 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"));
|
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
var token = tokenProvider.GetToken();
|
// KEINE globalen Header/Properties mehr dynamisch setzen. Authorization wird pro Request gesetzt.
|
||||||
if (!string.IsNullOrWhiteSpace(token))
|
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
||||||
|
|
||||||
_json = new JsonSerializerOptions {
|
_json = new JsonSerializerOptions {
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -35,9 +38,8 @@ internal sealed class ApiClient : IApiClient {
|
|||||||
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleInt32Converter());
|
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleInt32Converter());
|
||||||
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter());
|
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter());
|
||||||
|
|
||||||
// Stelle sicher, dass die BaseAddress sofort aus den aktuellen Settings (Preferences) gesetzt wird
|
// WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern – das verursacht Exceptions,
|
||||||
// und nicht erst beim ersten Request. Dadurch steht die ApiUrl ab Initialisierung zur Verfügung.
|
// sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request.
|
||||||
EnsureBaseAddress();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
public Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
||||||
@@ -45,18 +47,34 @@ internal sealed class ApiClient : IApiClient {
|
|||||||
|
|
||||||
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
|
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
|
||||||
IDictionary<string, string?>? query = null, CancellationToken ct = default) {
|
IDictionary<string, string?>? query = null, CancellationToken ct = default) {
|
||||||
// Vor jedem Request sicherstellen, dass die (ggf. geänderte) BaseAddress gesetzt ist
|
// Absolute URI aus aktuellem Settings‑BaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen.
|
||||||
EnsureBaseAddress();
|
var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query);
|
||||||
var uri = BuildUri(path, query);
|
using var req = new HttpRequestMessage(method, uri);
|
||||||
using var req = new HttpRequestMessage(method, uri) {
|
// Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist)
|
||||||
Content = body is null ? null : JsonContent.Create(body, options: _json)
|
// 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);
|
using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
//if (!res.IsSuccessStatusCode)
|
if (!res.IsSuccessStatusCode)
|
||||||
// throw ApiException.From(res.StatusCode, TryGetMessage(text), text);
|
throw ApiException.From(res.StatusCode, TryGetMessage(text), text);
|
||||||
|
|
||||||
if (res.StatusCode != System.Net.HttpStatusCode.OK) {
|
if (res.StatusCode != System.Net.HttpStatusCode.OK) {
|
||||||
// Verhalten wie in BaseFunc: bei Fehlerstatus -> "message" aus Body lesen und mit dessen Inhalt eine Exception werfen.
|
// Verhalten wie in BaseFunc: bei Fehlerstatus -> "message" aus Body lesen und mit dessen Inhalt eine Exception werfen.
|
||||||
@@ -91,22 +109,7 @@ internal sealed class ApiClient : IApiClient {
|
|||||||
public Task DeleteAsync(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
public Task DeleteAsync(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
||||||
=> SendAsync<object>(HttpMethod.Delete, path, null, query, ct);
|
=> SendAsync<object>(HttpMethod.Delete, path, null, query, ct);
|
||||||
|
|
||||||
private void EnsureBaseAddress() {
|
// Entfernt: EnsureBaseAddress – wir ändern BaseAddress nicht mehr dynamisch.
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string TryGetMessage(string text) {
|
private static string TryGetMessage(string text) {
|
||||||
try {
|
try {
|
||||||
@@ -119,15 +122,29 @@ internal sealed class ApiClient : IApiClient {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri BuildUri(string path, IDictionary<string, string?>? query) {
|
private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary<string, string?>? query) {
|
||||||
if (query is null || query.Count == 0)
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
return new Uri(path, UriKind.Relative);
|
throw new InvalidOperationException(
|
||||||
|
"ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl').");
|
||||||
|
|
||||||
var sb = new StringBuilder(path);
|
// Basis muss absolut sein (z. B. https://host/appapi/)
|
||||||
sb.Append(path.Contains('?') ? '&' : '?');
|
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
|
sb.Append(string.Join('&', query
|
||||||
.Where(kv => kv.Value is not null)
|
.Where(kv => kv.Value is not null)
|
||||||
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
|
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
|
||||||
return new Uri(sb.ToString(), UriKind.Relative);
|
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 Microsoft.Extensions.Logging;
|
||||||
using ZXing.Net.Maui.Controls;
|
using ZXing.Net.Maui.Controls;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using Jugenddienst_Stunden.ViewModels;
|
||||||
|
|
||||||
namespace Jugenddienst_Stunden;
|
namespace Jugenddienst_Stunden;
|
||||||
|
|
||||||
@@ -56,7 +57,16 @@ public static class MauiProgram {
|
|||||||
builder.Services.AddSingleton<ITokenProvider, SettingsTokenProvider>();
|
builder.Services.AddSingleton<ITokenProvider, SettingsTokenProvider>();
|
||||||
|
|
||||||
// HttpClient + ApiClient
|
// 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 => {
|
builder.Services.AddSingleton<IApiClient>(sp => {
|
||||||
var alert = sp.GetRequiredService<IAlertService>();
|
var alert = sp.GetRequiredService<IAlertService>();
|
||||||
try {
|
try {
|
||||||
@@ -88,10 +98,13 @@ public static class MauiProgram {
|
|||||||
// DI: Services & Repositories
|
// DI: Services & Repositories
|
||||||
builder.Services.AddSingleton<IHoursRepository, HoursRepository>();
|
builder.Services.AddSingleton<IHoursRepository, HoursRepository>();
|
||||||
builder.Services.AddSingleton<IHoursService, HoursService>();
|
builder.Services.AddSingleton<IHoursService, HoursService>();
|
||||||
|
builder.Services.AddSingleton<IAuthService, AuthService>();
|
||||||
|
|
||||||
// DI: Views/ViewModels
|
// DI: Views/ViewModels
|
||||||
builder.Services.AddTransient<ViewModels.StundenViewModel>();
|
builder.Services.AddTransient<ViewModels.StundenViewModel>();
|
||||||
builder.Services.AddTransient<Views.StundenPage>();
|
builder.Services.AddTransient<Views.StundenPage>();
|
||||||
|
builder.Services.AddTransient<ViewModels.LoginViewModel>();
|
||||||
|
builder.Services.AddTransient<Views.LoginPage>();
|
||||||
|
|
||||||
return builder.Build();
|
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;
|
namespace Jugenddienst_Stunden.Types;
|
||||||
|
|
||||||
internal class User {
|
public class User {
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string Surname { get; set; }
|
public string Surname { get; set; }
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
namespace Jugenddienst_Stunden.ViewModels;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Jugenddienst_Stunden.Interfaces;
|
||||||
|
|
||||||
|
namespace Jugenddienst_Stunden.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Die Loginseite
|
/// ViewModel für die Loginseite (MVVM)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LoginViewModel {
|
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);
|
||||||
|
|
||||||
|
public event EventHandler<string>? AlertEvent;
|
||||||
|
public event EventHandler<string>? InfoEvent;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Name der Anwendung
|
/// Name der Anwendung
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -14,20 +27,119 @@ public class LoginViewModel {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Version => AppInfo.VersionString;
|
public string Version => AppInfo.VersionString;
|
||||||
|
|
||||||
/// <summary>
|
[ObservableProperty]
|
||||||
/// Kurze Mitteilung für den Anwender
|
private string message = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite.";
|
||||||
/// </summary>
|
|
||||||
public string Message { get; set; } = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite.";
|
|
||||||
|
|
||||||
/// <summary>
|
[ObservableProperty]
|
||||||
/// Genutzer Server für die API
|
private string? server;
|
||||||
/// </summary>
|
|
||||||
public string Server { get; set; } = "Server: " + Preferences.Default.Get("apiUrl", "").Replace("/appapi", "")
|
|
||||||
.Replace("https://", "").Replace("http://", "");
|
|
||||||
|
|
||||||
/// <summary>
|
[ObservableProperty]
|
||||||
/// Titel der Seite - im Moment der aktuelle Anwender
|
private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt");
|
||||||
/// </summary>
|
|
||||||
public string Title { get; set; } = Preferences.Default.Get("name", "Nicht") + " " +
|
[ObservableProperty]
|
||||||
Preferences.Default.Get("surname", "eingeloggt");
|
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}">
|
Title="{Binding Title}">
|
||||||
|
|
||||||
|
|
||||||
<ContentPage.BindingContext>
|
<!-- BindingContext wird via DI im Code-Behind gesetzt -->
|
||||||
<models:LoginViewModel />
|
|
||||||
</ContentPage.BindingContext>
|
|
||||||
|
|
||||||
<ContentPage.Resources>
|
<ContentPage.Resources>
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<conv:StringVisibilityConverter x:Key="StringVisibilityConverter" />
|
<conv:StringVisibilityConverter x:Key="StringVisibilityConverter" />
|
||||||
|
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
|
||||||
|
<conv:EventArgsPassThroughConverter x:Key="EventArgsPassThroughConverter" />
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</ContentPage.Resources>
|
</ContentPage.Resources>
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</HorizontalStackLayout>
|
</HorizontalStackLayout>
|
||||||
<Grid Grid.Column="1" ColumnDefinitions="*,50" ColumnSpacing="10">
|
<Grid Grid.Column="1" ColumnDefinitions="*,50" ColumnSpacing="10">
|
||||||
<Label Text="Login QR/manuell" VerticalOptions="Center" Grid.Column="0" />
|
<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.Column="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -47,25 +47,30 @@
|
|||||||
<Label x:Name="ServerLabel" Text="{Binding Server}"
|
<Label x:Name="ServerLabel" Text="{Binding Server}"
|
||||||
IsVisible="{Binding Server, Converter={StaticResource StringVisibilityConverter}}" />
|
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 Text="Login mit QR-Code" FontSize="32" HorizontalOptions="Start" />
|
||||||
<Label x:Name="Message" Text="{Binding Message}" Margin="0,15" />
|
<Label x:Name="Message" Text="{Binding Message}" Margin="0,15" />
|
||||||
|
|
||||||
<Border HeightRequest="300" Padding="0">
|
<Border HeightRequest="300" Padding="0">
|
||||||
<zxing:CameraBarcodeReaderView
|
<zxing:CameraBarcodeReaderView
|
||||||
x:Name="barcodeScannerView"
|
x:Name="barcodeScannerView"
|
||||||
BarcodesDetected="BarcodesDetected"
|
VerticalOptions="FillAndExpand"
|
||||||
HorizontalOptions="FillAndExpand"
|
IsDetecting="{Binding IsDetecting}">
|
||||||
VerticalOptions="FillAndExpand" />
|
<zxing:CameraBarcodeReaderView.Behaviors>
|
||||||
|
<toolkit:EventToCommandBehavior EventName="BarcodesDetected"
|
||||||
|
Command="{Binding QrDetectedCommand}"
|
||||||
|
EventArgsConverter="{StaticResource EventArgsPassThroughConverter}" />
|
||||||
|
</zxing:CameraBarcodeReaderView.Behaviors>
|
||||||
|
</zxing:CameraBarcodeReaderView>
|
||||||
</Border>
|
</Border>
|
||||||
</VerticalStackLayout>
|
</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" />
|
<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="UsernameEntry" Text="{Binding Username}" Placeholder="Benutzername (Mailadresse)" Keyboard="Email" />
|
||||||
<Entry x:Name="PasswordEntry" Placeholder="Passwort" IsPassword="True" />
|
<Entry x:Name="PasswordEntry" Text="{Binding Password}" Placeholder="Passwort" IsPassword="True" />
|
||||||
<Entry x:Name="ServerEntry" Placeholder="Server (gleich wie im Browser)" Keyboard="Url" />
|
<Entry x:Name="ServerEntry" Text="{Binding Server}" Placeholder="Server (gleich wie im Browser)" Keyboard="Url" />
|
||||||
<Button Text="Login" Clicked="OnLoginButtonClicked"
|
<Button Text="Login" Command="{Binding LoginCommand}"
|
||||||
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" />
|
TextColor="{AppThemeBinding Dark={StaticResource White}, Light={StaticResource White}}" />
|
||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Jugenddienst_Stunden.Models;
|
using Jugenddienst_Stunden.Models;
|
||||||
using Jugenddienst_Stunden.Types;
|
using Jugenddienst_Stunden.Types;
|
||||||
|
using Jugenddienst_Stunden.ViewModels;
|
||||||
using ZXing.Net.Maui;
|
using ZXing.Net.Maui;
|
||||||
|
|
||||||
|
|
||||||
@@ -19,9 +20,38 @@ public partial class LoginPage : ContentPage {
|
|||||||
public LoginPage() {
|
public LoginPage() {
|
||||||
InitializeComponent();
|
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 =
|
barcodeScannerView.Options =
|
||||||
new BarcodeReaderOptions { Formats = BarcodeFormat.QrCode, AutoRotate = true, Multiple = false };
|
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) {
|
//if (BindingContext is LoginViewModel vm) {
|
||||||
// vm.AlertEvent += Vm_AlertEvent;
|
// vm.AlertEvent += Vm_AlertEvent;
|
||||||
// vm.InfoEvent += Vm_InfoEvent;
|
// 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
|
//Zwischen manuellem und automatischem Login (mit QR-Code) umschalten und die Schalterstellung merken
|
||||||
bool sqr = true;
|
// MVVM übernimmt Umschalten über IsManualMode im ViewModel; keine Code-Behind-Umschaltung mehr
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,13 +119,13 @@ public partial class LoginPage : ContentPage {
|
|||||||
base.OnDisappearing();
|
base.OnDisappearing();
|
||||||
|
|
||||||
barcodeScannerView.CameraLocation = CameraLocation.Front;
|
barcodeScannerView.CameraLocation = CameraLocation.Front;
|
||||||
barcodeScannerView.IsDetecting = false;
|
// IsDetecting wird via Binding vom ViewModel gesteuert
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnAppearing() {
|
protected override void OnAppearing() {
|
||||||
base.OnAppearing();
|
base.OnAppearing();
|
||||||
|
|
||||||
barcodeScannerView.IsDetecting = true;
|
// IsDetecting wird via Binding vom ViewModel gesteuert
|
||||||
barcodeScannerView.CameraLocation = CameraLocation.Rear;
|
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
|
//Zwischen manuellem und automatischem Login (mit QR-Code) umschalten und die Schalterstellung merken
|
||||||
private void Switch_Toggled(object sender, ToggledEventArgs e) {
|
// Umschalt-Logik erfolgt über Binding an IsManualMode im ViewModel
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//private void Vm_AlertEvent(object? sender, string e) {
|
//private void Vm_AlertEvent(object? sender, string e) {
|
||||||
// DisplayAlert("Fehler:", e, "OK");
|
// DisplayAlert("Fehler:", e, "OK");
|
||||||
|
|||||||
Reference in New Issue
Block a user