diff --git a/src/WorkTime.Mobile/App.xaml.cs b/src/WorkTime.Mobile/App.xaml.cs
index b079cca..6d3d7c1 100644
--- a/src/WorkTime.Mobile/App.xaml.cs
+++ b/src/WorkTime.Mobile/App.xaml.cs
@@ -1,8 +1,11 @@
-namespace WorkTime.Mobile;
+using MauiIcons.Core;
+
+namespace WorkTime.Mobile;
public partial class App : Application {
public App() {
InitializeComponent();
+ _ = new MauiIcon();
}
protected override Window CreateWindow(IActivationState? activationState) {
diff --git a/src/WorkTime.Mobile/AppShell.xaml b/src/WorkTime.Mobile/AppShell.xaml
index 19d4bd6..8d397c6 100644
--- a/src/WorkTime.Mobile/AppShell.xaml
+++ b/src/WorkTime.Mobile/AppShell.xaml
@@ -3,13 +3,24 @@
x:Class="WorkTime.Mobile.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
- xmlns:local="clr-namespace:WorkTime.Mobile"
- Shell.FlyoutBehavior="Flyout"
- Title="WorkTime.Mobile">
+ xmlns:pages="clr-namespace:WorkTime.Mobile.Views.Pages"
+ xmlns:icons="http://www.aathifmahir.com/dotnet/2022/maui/icons"
+ FlyoutBehavior="Disabled"
+ Title="Zeiterfassung">
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WorkTime.Mobile/Extensions.cs b/src/WorkTime.Mobile/Extensions.cs
new file mode 100644
index 0000000..a4d8b54
--- /dev/null
+++ b/src/WorkTime.Mobile/Extensions.cs
@@ -0,0 +1,17 @@
+using WorkTime.Shared.Models;
+
+namespace WorkTime.Mobile;
+
+public static class Extensions {
+
+ public static string GetActionName(this TimeEntryType type) {
+ return type switch {
+ TimeEntryType.Login => "Einstempeln",
+ TimeEntryType.Logout => "Ausstempeln",
+ TimeEntryType.LoginDrive => "Dienstreise starten",
+ TimeEntryType.LogoutDrive => "Dienstreise beenden",
+ _ => string.Empty
+ };
+ }
+
+}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/MainPage.xaml b/src/WorkTime.Mobile/MainPage.xaml
deleted file mode 100644
index c017f15..0000000
--- a/src/WorkTime.Mobile/MainPage.xaml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/WorkTime.Mobile/MainPage.xaml.cs b/src/WorkTime.Mobile/MainPage.xaml.cs
deleted file mode 100644
index 95155dc..0000000
--- a/src/WorkTime.Mobile/MainPage.xaml.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace WorkTime.Mobile;
-
-public partial class MainPage : ContentPage {
- int count = 0;
-
- public MainPage() {
- InitializeComponent();
- }
-
- private void OnCounterClicked(object sender, EventArgs e) {
- count++;
-
- if (count == 1)
- CounterBtn.Text = $"Clicked {count} time";
- else
- CounterBtn.Text = $"Clicked {count} times";
-
- SemanticScreenReader.Announce(CounterBtn.Text);
- }
-}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/MauiProgram.cs b/src/WorkTime.Mobile/MauiProgram.cs
index 78a2547..ba8ddfc 100644
--- a/src/WorkTime.Mobile/MauiProgram.cs
+++ b/src/WorkTime.Mobile/MauiProgram.cs
@@ -1,7 +1,8 @@
-using System.Net.Http.Headers;
+using MauiIcons.Material;
using Microsoft.Extensions.Logging;
using WorkTime.Mobile.Repositories;
using WorkTime.Mobile.Services;
+using WorkTime.Mobile.ViewModels;
using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services;
@@ -9,7 +10,7 @@ namespace WorkTime.Mobile;
public static class MauiProgram {
- public static string BackendUrl { get; private set; } = string.Empty; //TODO: Set production endpoint
+ private static string BackendUrl { get; set; } = string.Empty; //TODO: Set production endpoint
public static MauiApp CreateMauiApp() {
var builder = MauiApp.CreateBuilder();
@@ -18,25 +19,37 @@ public static class MauiProgram {
.ConfigureFonts(fonts => {
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
- });
+ })
+ .UseMaterialMauiIcons();
- builder.Services.AddScoped(provider => {
- var auth = provider.GetRequiredService();
+ builder.Services.AddSqlite($"Filename={Path.Combine(FileSystem.AppDataDirectory, "data.db")}");
+ builder.Services.AddSingleton(SecureStorage.Default);
+ builder.Services.AddScoped(_ => {
var client = new HttpClient();
client.BaseAddress = new Uri(BackendUrl);
- client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(auth.GetCurrentUserId().Result.ToString());
return client;
});
builder.Services.AddKeyedScoped("server");
builder.Services.AddKeyedScoped("client");
+ builder.Services.AddKeyedScoped("insecure");
+ builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ builder.Services.AddTransient();
#if DEBUG
builder.Logging.AddDebug();
BackendUrl = "https://localhost:7091/";
#endif
- return builder.Build();
+ var app = builder.Build();
+
+ using var scope = app.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureCreated();
+
+ return app;
}
}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Repositories/ClientEntryRepository.cs b/src/WorkTime.Mobile/Repositories/ClientEntryRepository.cs
index 31d6bd7..87e2ed3 100644
--- a/src/WorkTime.Mobile/Repositories/ClientEntryRepository.cs
+++ b/src/WorkTime.Mobile/Repositories/ClientEntryRepository.cs
@@ -35,5 +35,15 @@ public class ClientEntryRepository(DatabaseContext context) : IEntryRepository {
context.Entries.Remove(entry);
await context.SaveChangesAsync();
}
+
+ public async Task ReplaceEntries(IEnumerable entries, DateOnly date) {
+ var oldEntries = await context.Entries
+ .Where(entry => DateOnly.FromDateTime(entry.Timestamp) == date)
+ .ToArrayAsync();
+
+ context.Entries.RemoveRange(oldEntries);
+ await context.Entries.AddRangeAsync(entries);
+ await context.SaveChangesAsync();
+ }
}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Repositories/ServerEntryRepository.cs b/src/WorkTime.Mobile/Repositories/ServerEntryRepository.cs
index 532279c..f5445f2 100644
--- a/src/WorkTime.Mobile/Repositories/ServerEntryRepository.cs
+++ b/src/WorkTime.Mobile/Repositories/ServerEntryRepository.cs
@@ -1,35 +1,29 @@
-using System.Net.Http.Json;
+using WorkTime.Mobile.Services;
using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories;
namespace WorkTime.Mobile.Repositories;
-public class ServerEntryRepository(HttpClient client) : IEntryRepository {
+public class ServerEntryRepository(IHttpService client) : IEntryRepository {
public async Task GetAllEntries(Guid owner) {
- var response = await client.GetAsync($"entries/{owner}");
- if (!response.IsSuccessStatusCode) return [];
-
- return await response.Content.ReadFromJsonAsync() ?? [];
+ var response = await client.SendRequest(HttpMethod.Get, $"entries/{owner}");
+ return response.Result ?? [];
}
public async Task GetEntries(Guid owner, DateOnly date) {
- var response = await client.GetAsync($"entries/{owner}/{date.ToDateTime(TimeOnly.MinValue)}");
- if (!response.IsSuccessStatusCode) return [];
+ var response = await client.SendRequest(HttpMethod.Get, $"entries/{owner}/{date.ToDateTime(TimeOnly.MinValue)}");
+ return response.Result ?? [];
+ }
- return await response.Content.ReadFromJsonAsync() ?? [];
- }
-
- public Task GetEntry(int id) {
- throw new NotImplementedException();
- }
+ public Task GetEntry(int id) => Task.FromResult(null);
public async Task AddEntry(TimeEntry entry) {
- await client.PostAsJsonAsync("entries", entry);
+ await client.SendRequest(HttpMethod.Post, "entries", entry);
}
public async Task DeleteEntry(TimeEntry entry) {
- await client.DeleteAsync($"entries/{entry.Id}");
+ await client.SendRequest(HttpMethod.Delete, $"entries/{entry.Id}");
}
}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Services/AuthService.cs b/src/WorkTime.Mobile/Services/AuthService.cs
index f4e0646..7665415 100644
--- a/src/WorkTime.Mobile/Services/AuthService.cs
+++ b/src/WorkTime.Mobile/Services/AuthService.cs
@@ -2,7 +2,7 @@
namespace WorkTime.Mobile.Services;
-public class AuthService(ISecureStorage storage) : IAuthService {
+public class AuthService(ISecureStorage storage, [FromKeyedServices("insecure")] IHttpService httpService) : IAuthService {
public async Task IsAuthenticated() {
var value = await storage.GetAsync(IAuthService.HeaderName);
@@ -13,13 +13,8 @@ public class AuthService(ISecureStorage storage) : IAuthService {
var value = await storage.GetAsync(IAuthService.HeaderName);
if (string.IsNullOrWhiteSpace(value)) {
- var client = new HttpClient();
- client.BaseAddress = new Uri(MauiProgram.BackendUrl);
-
- var response = await client.GetAsync("auth/register");
- if (!response.IsSuccessStatusCode) return Guid.Empty;
-
- value = await response.Content.ReadAsStringAsync();
+ var response = await httpService.SendRequest(HttpMethod.Get, "auth/register");
+ value = response.Result ?? Guid.NewGuid().ToString();
}
return Guid.Parse(value);
diff --git a/src/WorkTime.Mobile/Services/EntryService.cs b/src/WorkTime.Mobile/Services/EntryService.cs
new file mode 100644
index 0000000..f7ee294
--- /dev/null
+++ b/src/WorkTime.Mobile/Services/EntryService.cs
@@ -0,0 +1,66 @@
+using WorkTime.Mobile.Repositories;
+using WorkTime.Shared.Models;
+using WorkTime.Shared.Repositories;
+using WorkTime.Shared.Services;
+
+namespace WorkTime.Mobile.Services;
+
+public class EntryService(
+ [FromKeyedServices("server")] IEntryRepository serverRepository,
+ [FromKeyedServices("client")] IEntryRepository clientRepository,
+ IAuthService authService) {
+
+ public async IAsyncEnumerable GetEntries(DateOnly date) {
+ var userId = await authService.GetCurrentUserId();
+ var clientEntries = await clientRepository.GetEntries(userId, date);
+ yield return new() {
+ NewBatch = true
+ };
+
+ foreach (var entry in clientEntries) {
+ yield return new() {
+ Entry = entry
+ };
+ }
+
+ var serverEntries = await serverRepository.GetEntries(userId, date);
+ if (serverEntries.Length == 0) yield break;
+
+ yield return new() {
+ NewBatch = true
+ };
+
+ foreach (var entry in serverEntries) {
+ yield return new() {
+ Entry = entry
+ };
+ }
+
+ await ((ClientEntryRepository)clientRepository).ReplaceEntries(serverEntries, date);
+ }
+
+ public async Task AddEntry(TimeEntry entry) {
+ var userId = await authService.GetCurrentUserId();
+ entry.Owner = userId;
+
+ await Task.WhenAll(
+ clientRepository.AddEntry(entry),
+ serverRepository.AddEntry(entry)
+ );
+ }
+
+ public async Task DeleteEntry(TimeEntry entry) {
+ await Task.WhenAll(
+ clientRepository.DeleteEntry(entry),
+ serverRepository.DeleteEntry(entry)
+ );
+ }
+}
+
+public readonly struct UpdatingEntriesResponse {
+
+ public bool NewBatch { get; init; }
+
+ public TimeEntry? Entry { get; init; }
+
+}
diff --git a/src/WorkTime.Mobile/Services/HttpService.cs b/src/WorkTime.Mobile/Services/HttpService.cs
new file mode 100644
index 0000000..1dcf68f
--- /dev/null
+++ b/src/WorkTime.Mobile/Services/HttpService.cs
@@ -0,0 +1,101 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using WorkTime.Shared.Services;
+
+namespace WorkTime.Mobile.Services;
+
+public interface IHttpService {
+ Task> SendRequest(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string uri, object? body = null);
+
+ Task SendRequest(HttpMethod method, [StringSyntax(StringSyntaxAttribute.Uri)] string uri, object? body = null);
+}
+
+internal class HttpService(HttpClient client, IAuthService authService) : IHttpService {
+
+ private InsecureHttpService? _service;
+
+ public async Task> SendRequest(HttpMethod method, string uri, object? body = null) {
+ var service = await GetInternalService();
+ return await service.SendRequest(method, uri, body);
+ }
+
+ public async Task SendRequest(HttpMethod method, string uri, object? body = null) {
+ var service = await GetInternalService();
+ return await service.SendRequest(method, uri, body);
+ }
+
+ private async Task GetInternalService() {
+ if (_service is null) {
+ var id = await authService.GetCurrentUserId();
+ client.DefaultRequestHeaders.Add(IAuthService.HeaderName, id.ToString());
+ _service = new InsecureHttpService(client);
+ }
+
+ return _service;
+ }
+
+}
+
+internal class InsecureHttpService(HttpClient client) : IHttpService {
+
+ public async Task> SendRequest(HttpMethod method, string uri, object? body = null) {
+ try {
+ var request = new HttpRequestMessage(method, uri);
+ if (body is not null)
+ request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
+
+ var response = await client.SendAsync(request);
+ var responseContent = await response.Content.ReadAsStringAsync();
+
+ return new HttpResponse {
+ Result = typeof(TResult) == typeof(string) ? (TResult)(object)responseContent : JsonSerializer.Deserialize(responseContent),
+ ResponseCode = response.StatusCode,
+ IsSuccessful = response.IsSuccessStatusCode
+ };
+ }
+ catch (Exception) {
+ return new() {
+ IsSuccessful = false,
+ ResponseCode = HttpStatusCode.InternalServerError
+ };
+ }
+ }
+
+ public async Task SendRequest(HttpMethod method, string uri, object? body = null) {
+ try {
+ var request = new HttpRequestMessage(method, uri);
+ if (body is not null)
+ request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
+
+ var response = await client.SendAsync(request);
+
+ return new HttpResponse {
+ ResponseCode = response.StatusCode,
+ IsSuccessful = response.IsSuccessStatusCode
+ };
+ }
+ catch (Exception) {
+ return new() {
+ IsSuccessful = false,
+ ResponseCode = HttpStatusCode.InternalServerError
+ };
+ }
+ }
+}
+
+public readonly struct HttpResponse {
+ public TResult? Result { get; init; }
+ public HttpStatusCode ResponseCode { get; init; }
+ public bool IsSuccessful { get; init; }
+
+ public static implicit operator TResult?(HttpResponse response) {
+ return response.Result;
+ }
+}
+
+public readonly struct HttpResponse {
+ public HttpStatusCode ResponseCode { get; init; }
+ public bool IsSuccessful { get; init; }
+}
diff --git a/src/WorkTime.Mobile/ViewModels/CaptureViewModel.cs b/src/WorkTime.Mobile/ViewModels/CaptureViewModel.cs
new file mode 100644
index 0000000..df66b03
--- /dev/null
+++ b/src/WorkTime.Mobile/ViewModels/CaptureViewModel.cs
@@ -0,0 +1,77 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using WorkTime.Mobile.Services;
+using WorkTime.Shared.Models;
+using WorkTime.Shared.Services;
+
+namespace WorkTime.Mobile.ViewModels;
+
+public partial class CaptureViewModel(EntryService entryService, IAuthService authService) : ObservableObject {
+
+ [ObservableProperty]
+ public partial ObservableCollection Entries { get; set; } = new();
+
+ [ObservableProperty]
+ public partial TimeEntryType CurrentAction { get; set; } = TimeEntryType.Login;
+
+ [ObservableProperty]
+ public partial bool CurrentlyMoba { get; set; }
+
+ public string CurrentActionName => CurrentAction.GetActionName();
+
+ [RelayCommand]
+ private async Task OnDateSelected(DateOnly date) {
+ await foreach (var entryResponse in entryService.GetEntries(date)) {
+ if (entryResponse.NewBatch)
+ Entries.Clear();
+
+ if (entryResponse.Entry is not null)
+ Entries.Add(entryResponse.Entry);
+ }
+
+ UpdateCurrentAction();
+ }
+
+ [RelayCommand]
+ private async Task RegisterEntry(TimeEntry? entry = null) {
+ entry ??= new TimeEntry {
+ Type = CurrentAction,
+ IsMoba = CurrentlyMoba,
+ Owner = await authService.GetCurrentUserId()
+ };
+
+ var insertIndex = Entries.Index()
+ .LastOrDefault(e => e.Item.Timestamp < entry.Timestamp)
+ .Index;
+
+ if (Entries.Count == insertIndex + 1 || Entries.Count == 0)
+ Entries.Add(entry);
+ else Entries.Insert(insertIndex + 1, entry);
+
+ await entryService.AddEntry(entry);
+ UpdateCurrentAction();
+ }
+
+ private void UpdateCurrentAction() {
+ if (Entries.Count == 0) {
+ CurrentAction = TimeEntryType.Login;
+ CurrentlyMoba = false;
+ return;
+ }
+
+ var lastEntry = Entries[^1];
+ CurrentAction = lastEntry.Type switch {
+ TimeEntryType.Login => TimeEntryType.Logout,
+ TimeEntryType.Logout => TimeEntryType.Login,
+ TimeEntryType.LoginDrive => TimeEntryType.LogoutDrive,
+ TimeEntryType.LogoutDrive => TimeEntryType.Login,
+ _ => TimeEntryType.Login
+ };
+ CurrentlyMoba = lastEntry is { Type: TimeEntryType.Login, IsMoba: true };
+ }
+
+ partial void OnCurrentActionChanged(TimeEntryType value) {
+ OnPropertyChanged(nameof(CurrentActionName));
+ }
+}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Views/Components/DateSelector.xaml b/src/WorkTime.Mobile/Views/Components/DateSelector.xaml
new file mode 100644
index 0000000..fe94b14
--- /dev/null
+++ b/src/WorkTime.Mobile/Views/Components/DateSelector.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Views/Components/DateSelector.xaml.cs b/src/WorkTime.Mobile/Views/Components/DateSelector.xaml.cs
new file mode 100644
index 0000000..04e2d73
--- /dev/null
+++ b/src/WorkTime.Mobile/Views/Components/DateSelector.xaml.cs
@@ -0,0 +1,47 @@
+using CommunityToolkit.Mvvm.Input;
+
+namespace WorkTime.Mobile.Views.Components;
+
+public partial class DateSelector : ContentView {
+
+ public static readonly BindableProperty CurrentDateProperty = BindableProperty.Create(
+ nameof(CurrentDate),
+ typeof(DateTime),
+ typeof(DateSelector),
+ DateTime.Now);
+
+ public static readonly BindableProperty MaxDateProperty = BindableProperty.Create(
+ nameof(MaxDate),
+ typeof(DateTime),
+ typeof(DateSelector),
+ DateTime.Now);
+
+ public static readonly BindableProperty CommandProperty = BindableProperty.Create(
+ nameof(Command),
+ typeof(IRelayCommand),
+ typeof(DateSelector));
+
+ public DateTime CurrentDate {
+ get => (DateTime)GetValue(CurrentDateProperty);
+ set => SetValue(CurrentDateProperty, value);
+ }
+
+ public DateTime MaxDate {
+ get => (DateTime)GetValue(MaxDateProperty);
+ set => SetValue(MaxDateProperty, value);
+ }
+
+ public IRelayCommand? Command {
+ get => (IRelayCommand)GetValue(CommandProperty);
+ set => SetValue(CommandProperty, value);
+ }
+
+ public DateSelector() {
+ InitializeComponent();
+ }
+
+ private void OnDateSelected(object? sender, DateChangedEventArgs e) {
+ var date = DateOnly.FromDateTime(CurrentDate);
+ Command?.Execute(date);
+ }
+}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Views/Pages/CapturePage.xaml b/src/WorkTime.Mobile/Views/Pages/CapturePage.xaml
new file mode 100644
index 0000000..e1cc500
--- /dev/null
+++ b/src/WorkTime.Mobile/Views/Pages/CapturePage.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/Views/Pages/CapturePage.xaml.cs b/src/WorkTime.Mobile/Views/Pages/CapturePage.xaml.cs
new file mode 100644
index 0000000..5b890ca
--- /dev/null
+++ b/src/WorkTime.Mobile/Views/Pages/CapturePage.xaml.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using WorkTime.Mobile.ViewModels;
+
+namespace WorkTime.Mobile.Views.Pages;
+
+public partial class CapturePage : ContentPage {
+ public CapturePage(CaptureViewModel model) {
+ InitializeComponent();
+ BindingContext = model;
+ }
+}
\ No newline at end of file
diff --git a/src/WorkTime.Mobile/WorkTime.Mobile.csproj b/src/WorkTime.Mobile/WorkTime.Mobile.csproj
index cc0b1aa..e0c7708 100644
--- a/src/WorkTime.Mobile/WorkTime.Mobile.csproj
+++ b/src/WorkTime.Mobile/WorkTime.Mobile.csproj
@@ -19,12 +19,14 @@
true
enable
enable
+ preview
+ true
- WorkTime.Mobile
+ Zeiterfassung
- com.companyname.worktime.mobile
+ de.leon-hoppe.worktime.mobile
1.0
@@ -60,6 +62,8 @@
+
+