diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6ef2bb5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/net9.0-windows10.0.19041.0/win-x64/FrymasterBadgeApp.dll", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..53bfb34 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/FrymasterBadgeApp.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/FrymasterBadgeApp.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/FrymasterBadgeApp.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/App.xaml b/App.xaml index 953d5f5..cd544a8 100644 --- a/App.xaml +++ b/App.xaml @@ -1,15 +1,17 @@ - + - - - - - - - - + x:Class="FrymasterBadgeApp.App" + xmlns="http://schemas.microsoft.com/dotnet/2021/maui" + xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" + xmlns:local="clr-namespace:FrymasterBadgeApp"> + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs index 3117ddd..4766af0 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,176 +1,99 @@ -using System.Diagnostics; -using FrymasterBadgeApp.Services; -using Microsoft.Maui.Controls; -using Microsoft.Maui.Controls.PlatformConfiguration.WindowsSpecific; +namespace FrymasterBadgeApp; -namespace FrymasterBadgeApp; - -public partial class App : Microsoft.Maui.Controls.Application +public partial class App : Application { - private readonly SqlService _db; - private readonly PrinterService _printerService; - private Microsoft.Maui.Controls.TabbedPage _rootTabbedPage; + private readonly IServiceProvider _serviceProvider; + private const string ThemePrefKey = "AppTheme"; // Must match SettingsPage - public App(SqlService db, PrinterService printerService) + public App(IServiceProvider serviceProvider) { - InitializeComponent(); - _db = db; - _printerService = printerService; - - Debug.WriteLine("=== APP STARTUP ==="); - - _rootTabbedPage = new Microsoft.Maui.Controls.TabbedPage - { - Title = "Frymaster Badge System", - BarBackgroundColor = Colors.SlateGray, - BarTextColor = Colors.White, - }; - - _rootTabbedPage - .On() - .SetHeaderIconsEnabled(false); - - var loadingPage = new Microsoft.Maui.Controls.ContentPage - { - Title = "Loading", - BackgroundColor = Colors.DarkSlateBlue, - Content = new StackLayout - { - VerticalOptions = LayoutOptions.Center, - HorizontalOptions = LayoutOptions.Center, - Children = - { - new ActivityIndicator - { - IsRunning = true, - Color = Colors.White, - Scale = 2, - }, - new Microsoft.Maui.Controls.Label - { - Text = "Loading companies...", - TextColor = Colors.White, - FontSize = 18, - }, - }, - }, - }; - - _rootTabbedPage.Children.Add(loadingPage); - MainPage = _rootTabbedPage; - - Debug.WriteLine("App: Resources loaded, loading page added, TabbedPage set as MainPage"); - } - - protected override async void OnStart() - { - base.OnStart(); - Debug.WriteLine("App: OnStart - calling LoadTabsAsync"); - await LoadTabsAsync(); - } - - private async Task LoadTabsAsync() - { - Debug.WriteLine("LoadTabsAsync: ENTERED"); - + AppLogger.Info("App: Constructor - Internal setup beginning"); try { - Debug.WriteLine("LoadTabsAsync: Querying database for companies..."); - var companies = await Task.Run(() => _db.Query("SELECT * FROM dbo.Companies", null)); - - Debug.WriteLine($"LoadTabsAsync: Query returned {companies?.Count ?? 0} companies"); - - await MainThread.InvokeOnMainThreadAsync(async () => - { - Debug.WriteLine("LoadTabsAsync: On main thread - delay"); - await Task.Delay(200); - - Debug.WriteLine( - $"LoadTabsAsync: Current children count before clear: {_rootTabbedPage.Children.Count}" - ); - _rootTabbedPage.Children.Clear(); - Debug.WriteLine("LoadTabsAsync: Children cleared"); - - if (companies == null || !companies.Any()) - { - Debug.WriteLine("LoadTabsAsync: No companies - adding setup page"); - - var setupPage = new Microsoft.Maui.Controls.ContentPage - { - Title = "Setup", - BackgroundColor = Colors.Purple, - Content = new StackLayout - { - VerticalOptions = LayoutOptions.Center, - HorizontalOptions = LayoutOptions.Center, - Children = - { - new Microsoft.Maui.Controls.Label - { - Text = "No companies found - click to manage", - TextColor = Colors.White, - FontSize = 24, - }, - new Microsoft.Maui.Controls.Button - { - Text = "Manage Companies", - Command = new Command(async () => - await Microsoft.Maui.Controls.Application.Current.MainPage.Navigation.PushAsync( - new CompanyPage(_db) - ) - ), - }, - }, - }, - }; - - _rootTabbedPage.Children.Add(setupPage); - Debug.WriteLine("LoadTabsAsync: Setup page added"); - return; - } - - Debug.WriteLine($"LoadTabsAsync: Adding {companies.Count} real tabs"); - - int added = 0; - foreach (var company in companies) - { - string companyName = company.GetValueOrDefault("Name")?.ToString() ?? "Unknown"; - Debug.WriteLine($"LoadTabsAsync: Processing company '{companyName}'"); - - var employeePage = new EmployeePage(_db, _printerService, company); - employeePage.Title = companyName; - employeePage.BackgroundColor = Colors.LightBlue; // Temporary for visibility - - employeePage.ToolbarItems.Add( - new ToolbarItem - { - Text = "Manage", - Command = new Command(async () => - await employeePage.Navigation.PushAsync(new CompanyPage(_db)) - ), - } - ); - - _rootTabbedPage.Children.Add(employeePage); - added++; - Debug.WriteLine( - $"LoadTabsAsync: Added tab '{companyName}' ({added}/{companies.Count})" - ); - } - - Debug.WriteLine($"LoadTabsAsync: Finished - {added} tabs added"); - }); + InitializeComponent(); + ApplySavedTheme(); // Set the theme before resolving Shell + AppLogger.Info("App: InitializeComponent and Theme application successful"); } catch (Exception ex) { - Debug.WriteLine($"LoadTabsAsync: EXCEPTION - {ex.Message}"); - Debug.WriteLine(ex.StackTrace); + AppLogger.Error("App: FATAL XAML ERROR", ex); + throw; + } + _serviceProvider = serviceProvider; + } + +/// +/// Applies the saved theme preference to the app. +/// If the preference could not be applied, a warning is logged. +/// + private void ApplySavedTheme() + { + try + { + var pref = Microsoft.Maui.Storage.Preferences.Default.Get(ThemePrefKey, "System"); + AppLogger.Info($"App: Applying saved theme preference: {pref}"); + + Application.Current.UserAppTheme = pref switch + { + "Light" => AppTheme.Light, + "Dark" => AppTheme.Dark, + _ => AppTheme.Unspecified + }; + } + catch (Exception ex) + { + AppLogger.Warn($"App: Failed to apply theme preference: {ex.Message}"); } } +/// +/// Creates a new instance of the required AppShell service and +/// opens a new window with the specified content. +/// protected override Window CreateWindow(IActivationState? activationState) { - Debug.WriteLine("CreateWindow called"); - return new Window(MainPage!); + AppLogger.Info("App: CreateWindow entered"); + try + { + var shell = _serviceProvider.GetRequiredService(); + AppLogger.Info("App: AppShell resolved successfully"); + return new Window(shell); + } + catch (Exception ex) + { + AppLogger.Error("CRITICAL STARTUP ERROR: Dependency Resolution Failed", ex); + + return new Window( + new ContentPage + { + Content = new VerticalStackLayout + { + VerticalOptions = LayoutOptions.Center, + Padding = 20, + Children = + { + new Label + { + Text = "Startup Failed", + FontAttributes = FontAttributes.Bold, + HorizontalTextAlignment = TextAlignment.Center, + FontSize = 20 + }, + new Label + { + Text = ex.Message, + HorizontalTextAlignment = TextAlignment.Center, + TextColor = Colors.Red, + }, + new Label + { + Text = "Check log for details.", + HorizontalTextAlignment = TextAlignment.Center, + }, + } + } + } + ); + } } -} +} \ No newline at end of file diff --git a/App.xaml.cs.md b/App.xaml.cs.md new file mode 100644 index 0000000..22e0a5e --- /dev/null +++ b/App.xaml.cs.md @@ -0,0 +1,110 @@ +# App Class Documentation + +## Overview + +The `App` class is the main entry point for the **FrymasterBadgeApp** .NET MAUI application. It inherits from `Microsoft.Maui.Controls.Application` and is responsible for: + +- Initializing the application components +- Applying the user's saved theme preference +- Setting up dependency injection +- Creating the main application window with robust error handling + +## Namespace + +```csharp +namespace FrymasterBadgeApp; +``` + +## Class Definition + +```csharp +public partial class App : Application +``` + +## Fields + +| Field | Type | Description | +|------------------|-------------------|-----------| +| `_serviceProvider` | `IServiceProvider` | Holds the dependency injection container used to resolve services like `AppShell`. | +| `ThemePrefKey` | `const string` | Constant key used to store and retrieve the app theme preference. Must match the key used in `SettingsPage`. | + +## Constructor + +```csharp +public App(IServiceProvider serviceProvider) +``` + +### Purpose +Initializes the application, applies the saved theme, and stores the service provider for later use. + +### Behavior +1. Logs the start of constructor execution. +2. Calls `InitializeComponent()` to load XAML-defined UI elements. +3. Applies the saved user theme preference via `ApplySavedTheme()`. +4. Stores the injected `IServiceProvider`. +5. Catches and logs any fatal XAML initialization errors. + +## Methods + +### ApplySavedTheme() + +```csharp +private void ApplySavedTheme() +``` + +**Summary**: +Applies the user's previously saved theme preference (Light, Dark, or System) to the application. + +**Behavior**: +- Retrieves the theme preference from `Preferences.Default` using the key `"AppTheme"`. +- Defaults to `"System"` if no preference is found. +- Maps the string preference to the corresponding `AppTheme` enum value. +- Sets `Application.Current.UserAppTheme`. +- Logs the applied theme or any failure (as a warning). + +### CreateWindow(IActivationState? activationState) + +```csharp +protected override Window CreateWindow(IActivationState? activationState) +``` + +**Summary**: +Overrides the default window creation to provide proper dependency injection support and robust error handling. + +**Behavior**: +- Logs entry into the method. +- Resolves `AppShell` using the injected `IServiceProvider`. +- Returns a new `Window` containing the resolved `AppShell`. +- If dependency resolution fails (e.g., missing service registration), a user-friendly error page is displayed instead of crashing the app. +- The fallback error page shows: + - "Startup Failed" title + - The exception message in red + - A note to check the log for details + +## Key Features + +- **Theme Persistence**: Automatically restores the user's last chosen theme on startup. +- **Dependency Injection Ready**: Uses `IServiceProvider` to resolve the main `AppShell`. +- **Graceful Error Handling**: Critical startup failures are caught and presented in a clean error screen rather than a hard crash. +- **Comprehensive Logging**: Uses `AppLogger` for all major initialization steps and errors. + +## Dependencies + +- `Microsoft.Maui.Controls.Application` +- `Microsoft.Maui.Storage.Preferences` +- `IServiceProvider` (from Microsoft.Extensions.DependencyInjection) +- `AppShell` +- `AppLogger` (custom logging service) + +## Related Files + +- `SettingsPage.xaml.cs` — Uses the same `ThemePrefKey` constant +- `AppShell.xaml` — Main navigation shell resolved in `CreateWindow` +- `MauiProgram.cs` — Where services are registered and `App` is configured + +## Best Practices Implemented + +- Separation of theme logic into a dedicated method +- Defensive programming with try-catch blocks around critical startup operations +- Meaningful logging at each stage of initialization +- Fallback UI for startup failures to improve user experience \ No newline at end of file diff --git a/App.xaml.md b/App.xaml.md new file mode 100644 index 0000000..fd7f240 --- /dev/null +++ b/App.xaml.md @@ -0,0 +1,79 @@ +# App.xaml Documentation + +## Overview + +This is the main XAML file for the **FrymasterBadgeApp** .NET MAUI application. It defines the root `Application` object and sets up global resources that are available throughout the entire app. + +## File Information + +- **File**: `App.xaml` +- **Class**: `FrymasterBadgeApp.App` (partial class, paired with `App.xaml.cs`) +- **Purpose**: Application-level configuration and resource management + +## XAML Content + +\`\`\`xaml + + + + + + + + + + + + + +\`\`\` + +## Key Elements + +### Root Element + +- **``** — Defines the application root. +- **`x:Class="FrymasterBadgeApp.App"`** — Links this XAML file to the partial C# class `App` in `App.xaml.cs`. + +### XML Namespaces + +| Namespace | Prefix | Description | +|-----------|--------|-----------| +| `http://schemas.microsoft.com/dotnet/2021/maui` | (default) | Standard .NET MAUI namespace | +| `http://schemas.microsoft.com/winfx/2009/xaml` | `x` | XAML language namespace | +| `clr-namespace:FrymasterBadgeApp` | `local` | Local application namespace (currently unused in this file) | + +### Resources + +The file defines **application-wide resources** inside ``: + +- A `ResourceDictionary` that merges two external style files: + - **`Resources/Styles/Colors.xaml`** — Contains color definitions and brushes used across the app. + - **`Resources/Styles/Styles.xaml`** — Contains control styles, implicit styles, and other visual resources. + +## Purpose & Role + +- Serves as the central location for app-level resources. +- Ensures consistent theming and styling across all pages and controls. +- Loads color and style definitions early in the application lifecycle. +- Works together with `App.xaml.cs` (the code-behind) which handles logic such as theme application and window creation. + +## Related Files + +- **`App.xaml.cs`** — Code-behind containing constructor, theme logic, and `CreateWindow` override. +- **`Resources/Styles/Colors.xaml`** — Color palette and resource definitions. +- **`Resources/Styles/Styles.xaml`** — Global styles and control templates. +- **`MauiProgram.cs`** — Service registration and app startup configuration. + +## Best Practices Demonstrated + +- Clean separation of concerns: XAML for resources, C# for logic. +- Use of `MergedDictionaries` for modular and maintainable styling. +- Proper namespace declarations for future extensibility. +- Standard .NET MAUI application structure. + +This file is loaded automatically when the application starts, before any pages are displayed. \ No newline at end of file diff --git a/AppLogger.cs b/AppLogger.cs new file mode 100644 index 0000000..9d93a55 --- /dev/null +++ b/AppLogger.cs @@ -0,0 +1,105 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace FrymasterBadgeApp; + +public static class AppLogger +{ + private static readonly string LogDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData + ); + private static readonly string BaseLogName = "FrymasterBadgeApp"; + private static readonly long MaxFileSizeBytes = 5 * 1024 * 1024; // 5MB + private static readonly object _lock = new(); + + static AppLogger() + { + Log("DEBUG", "AppLogger initialized"); + } + + private static string GetCurrentLogPath() + { + string date = DateTime.Now.ToString("yyyy-MM-dd"); + return Path.Combine(LogDirectory, $"{BaseLogName}_{date}.log"); + } + + public static void Info(string message) => Log("INFO", message); + + public static void Warn(string message) => Log("WARN", message); + + public static void Warning(string message) => Log("WARNING", message); + + /// + /// Logs an error to the application log, including the given message. + /// The message to be logged.The exception associated with the error, or null if none. + public static void Error(string message, Exception? ex = null) + { + string full = ex != null ? $"{message}\nException: {ex}" : message; + Log("ERROR", full); + } + + /// + /// Logs a a instructions to the application log, including the given message. + /// The message to be logged. + public static void Debug(string message) + { +#if DEBUG + Log("DEBUG", message); +#endif + } + + /// + /// Logs a message to the application log, including the given message. + /// + private static void Log(string level, string message) + { + string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + string logLine = $"[{timestamp}] [{level}] {message}"; + + lock (_lock) + { + string logPath = GetCurrentLogPath(); + + // Rotate if too large + if (File.Exists(logPath) && new FileInfo(logPath).Length > MaxFileSizeBytes) + { + string backup = logPath + ".old"; + if (File.Exists(backup)) + File.Delete(backup); + File.Move(logPath, backup); + } + + try + { + File.AppendAllText(logPath, logLine + Environment.NewLine); + } + catch (Exception ex) + { + Console.WriteLine($"Log file write failed: {ex.Message}"); + } + + Console.WriteLine(logLine); + System.Diagnostics.Debug.WriteLine(logLine); + } + } + + /// + /// Deletes old log files that are older than the given number of days. + /// + public static void CleanupOldLogs(int daysToKeep = 30) + { + var cutoff = DateTime.Now.AddDays(-daysToKeep); + foreach (var file in Directory.GetFiles(LogDirectory, $"{BaseLogName}_*.log")) + { + if (File.GetCreationTime(file) < cutoff) + { + try + { + File.Delete(file); + } + catch { } + } + } + } +} diff --git a/AppShell.xaml b/AppShell.xaml index f4f9882..1fbc7e6 100644 --- a/AppShell.xaml +++ b/AppShell.xaml @@ -1,15 +1,53 @@ - + x:Class="FrymasterBadgeApp.AppShell" + xmlns="http://schemas.microsoft.com/dotnet/2021/maui" + xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" + xmlns:pages="clr-namespace:FrymasterBadgeApp" + x:Name="thisShell" + Shell.FlyoutBehavior="Disabled" + + Shell.BackgroundColor="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource BgDark}}" + + Shell.ForegroundColor="White" + Shell.TitleColor="White" + + Shell.TabBarBackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource CardDark}}" + Shell.TabBarForegroundColor="{StaticResource Primary}" + Shell.TabBarTitleColor="{StaticResource Primary}" + Shell.TabBarUnselectedColor="{StaticResource Gray500}" + Shell.UnselectedColor="{StaticResource Gray500}"> - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AppShell.xaml.cs b/AppShell.xaml.cs index bd96a17..bacaac1 100644 --- a/AppShell.xaml.cs +++ b/AppShell.xaml.cs @@ -1,5 +1,6 @@ +using System.Diagnostics; using FrymasterBadgeApp.Services; -using Microsoft.Maui.Controls; +using Microsoft.Maui.Storage; namespace FrymasterBadgeApp; @@ -7,66 +8,151 @@ public partial class AppShell : Shell { private readonly SqlService _db; private readonly PrinterService _printerService; + private readonly IServiceProvider _serviceProvider; + private bool _isInitialized = false; - public AppShell(SqlService db, PrinterService printerService) +/// +/// AppShell is a class that contains the initialization code for the app. It is responsible for +/// loading all companies in the background and then switching to the main thread to +/// update the UI. +/// + public AppShell(SqlService db, PrinterService printerService, IServiceProvider serviceProvider) { InitializeComponent(); _db = db; _printerService = printerService; + _serviceProvider = serviceProvider; - _ = LoadCompaniesAsync(); + // Register pages that are NOT part of the TabBar permanently + Routing.RegisterRoute(nameof(SettingsPage), typeof(SettingsPage)); + Routing.RegisterRoute(nameof(CompanyPage), typeof(CompanyPage)); + Routing.RegisterRoute(nameof(EmployeePage), typeof(EmployeePage)); } + protected override async void OnAppearing() + { + base.OnAppearing(); + + // Using a small delay or ensuring initialization happens once + if (!_isInitialized) + { + _isInitialized = true; + await LoadCompaniesAsync(); + } + } + +/// +/// Loads all companies in the background and then switches to the main thread to update the UI. +/// private async Task LoadCompaniesAsync() { try { + // 1. Fetch data in background + AppLogger.Debug("AppShell: Fetching companies"); var companies = await Task.Run(() => _db.Query("SELECT * FROM dbo.Companies", null)); - await MainThread.InvokeOnMainThreadAsync(() => + // 2. Switch to Main Thread for UI changes + await MainThread.InvokeOnMainThreadAsync(async () => { - Items.Clear(); + // Clear the "Loading" item + MainTabBar.Items.Clear(); if (companies == null || !companies.Any()) { - CurrentItem = SetupTab; - return; + MainTabBar.Items.Add( + new ShellContent + { + Title = "Setup", + Route = "InitialSetup", + ContentTemplate = new DataTemplate(() => + _serviceProvider.GetRequiredService() + ), + } + ); } - - foreach (var company in companies) + else { - string companyName = company.GetValueOrDefault("Name")?.ToString() ?? "Unknown"; - - var employeePage = new EmployeePage(_db, _printerService, company) + foreach (var company in companies) { - Title = companyName, - }; - - var manageBtn = new ToolbarItem - { - Text = "Manage Companies", - Order = ToolbarItemOrder.Primary, - Command = new Command(async () => - await employeePage.Navigation.PushAsync(new CompanyPage(_db)) - ), - }; - employeePage.ToolbarItems.Add(manageBtn); - - var tab = new Tab { Title = companyName }; - tab.Items.Add(new ShellContent { Content = employeePage }); - - MainTabBar.Items.Add(tab); + var companyId = company.GetValueOrDefault("ID")?.ToString() ?? "0"; + MainTabBar.Items.Add( + new ShellContent + { + Title = company.GetValueOrDefault("Name")?.ToString() ?? "Unknown", + Route = $"EmployeePage_{companyId}", + ContentTemplate = new DataTemplate(() => + new EmployeePage(_db, _printerService, company) + ), + } + ); + } } + // 3. Navigate away from the Loading page to the first real tab if (MainTabBar.Items.Count > 0) - CurrentItem = MainTabBar.Items[0]; + { + var targetRoute = MainTabBar.Items[0].Route; + // The "///" is critical—it tells Shell to rebuild the navigation stack + await Shell.Current.GoToAsync($"///{targetRoute}"); + } }); } catch (Exception ex) { - await MainThread.InvokeOnMainThreadAsync(async () => - await DisplayAlert("Error", $"Failed to load: {ex.Message}", "OK") - ); + AppLogger.Error("AppShell: Error loading companies", ex); + // Fallback: If DB fails, at least show the Company page + await Shell.Current.GoToAsync($"///InitialSetup"); } } + +/// +/// Navigates to CompanyPage as a sub-page (pushed onto the stack). + public async void OnManageCompaniesClicked(object sender, EventArgs e) + { + // Navigates to CompanyPage as a sub-page (pushed onto the stack) + await Shell.Current.GoToAsync(nameof(CompanyPage)); + } + + private async void OnSettingsClicked(object sender, EventArgs e) => + await Shell.Current.GoToAsync(nameof(SettingsPage)); + +/// +/// Open a simple action sheet to choose theme globally +/// +/// The object that triggered the event +/// The event arguments + private async void OnThemeClicked(object sender, EventArgs e) + { + // Open a simple action sheet to choose theme globally + string action = await Shell.Current.DisplayActionSheet("Select Theme", "Cancel", null, "System", "Light", "Dark"); + if (action == "Cancel" || string.IsNullOrEmpty(action)) + return; + + switch (action) + { + case "Light": + Application.Current.UserAppTheme = AppTheme.Light; + Preferences.Default.Set("AppTheme", "Light"); + break; + case "Dark": + Application.Current.UserAppTheme = AppTheme.Dark; + Preferences.Default.Set("AppTheme", "Dark"); + break; + default: + Application.Current.UserAppTheme = AppTheme.Unspecified; + Preferences.Default.Set("AppTheme", "System"); + break; + } + } + + private void OnExitClicked(object sender, EventArgs e) => Application.Current?.Quit(); + +/// +/// Open a simple action sheet to show the app's about page +/// +/// The object that triggered the event +/// The event arguments + private async void OnAboutClicked(object sender, EventArgs e) => + await DisplayAlert("About", "Frymaster Badge App v1.0", "OK"); } diff --git a/BadgePrinterPlatforms/Windows/App.xbf b/BadgePrinterPlatforms/Windows/App.xbf new file mode 100644 index 0000000..86ad98d Binary files /dev/null and b/BadgePrinterPlatforms/Windows/App.xbf differ diff --git a/CompanyPage.xaml b/CompanyPage.xaml index 0f3a2b2..354a22f 100644 --- a/CompanyPage.xaml +++ b/CompanyPage.xaml @@ -1,125 +1,169 @@ - - - - - - + x:Class="FrymasterBadgeApp.CompanyPage" + xmlns="http://schemas.microsoft.com/dotnet/2021/maui" + xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" + Title="Company Settings"> - - - - - public App() - { - this.InitializeComponent(); - } + public App() + { + this.InitializeComponent(); + } protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); } diff --git a/README.md b/README.md new file mode 100644 index 0000000..878d238 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# Frymaster Badge App + +A cross-platform **.NET MAUI** application for managing employee records, generating customizable ID badges, handling company profiles, and printing via local desktop printers. Features dynamic tab navigation, photo capture/cropping, barcode generation, and robust file-based logging. + +--- + +## 📋 Prerequisites + +| Requirement | Details | +|-------------|---------| +| **SDK** | `.NET 8.0` or later | +| **Workload** | `maui` workload installed (`dotnet workload install maui`) | +| **IDE** | Visual Studio 2022 (v17.8+) with MAUI, or VS Code + C# Dev Kit | +| **Database** | SQL Server (2019/2022/Express/Azure SQL) | +| **Platform** | Windows 10/11 (recommended for printing & file pickers) | + +> ⚠️ **Note:** Printer APIs (`System.Drawing.Printing`) and folder/file pickers used in this app are desktop-optimized. Mobile targets may require platform-specific fallbacks. + +--- + +## 🗄️ Database Setup + +1. Create a database named `Frymaster` in your SQL Server instance. +2. Run the following schema to create the required tables (matches application queries): + +```sql +CREATE TABLE dbo.Companies ( + ID INT IDENTITY(1,1) PRIMARY KEY, + Name NVARCHAR(100) NOT NULL, + Address NVARCHAR(200), + City NVARCHAR(100), + State NVARCHAR(50), + Zip NVARCHAR(20), + Logo NVARCHAR(255) +); + +CREATE TABLE dbo.tblData ( + Data1 NVARCHAR(50) PRIMARY KEY, -- Employee Record Number + Data2 NVARCHAR(100), -- First Name + LastName NVARCHAR(100), + Data3 NVARCHAR(100), -- Display Name + Data4 NVARCHAR(100), -- Additional Info + Data5 DATETIME, -- Issue/Start Date + Data7 NVARCHAR(10), -- Medical Indicator 1 (True/False) + Data8 NVARCHAR(10), -- Medical Indicator 2 (1/0 or True/False) + Active NVARCHAR(10), -- YES/NO + Data9 NVARCHAR(50), -- Badge Number + picCode NVARCHAR(255), -- Photo filename reference + cropX FLOAT DEFAULT 0, + cropY FLOAT DEFAULT 0, + cropScale FLOAT DEFAULT 1.0, + BadgeType NVARCHAR(50) DEFAULT 'OFFICE', -- OFFICE, GUEST, PLANT, MAINTENANCE, VENDOR + ToPrint BIT DEFAULT 0, + CdsPrinted BIT DEFAULT 0 +); +``` + +--- + +## ⚙️ Configuration + +1. Create `appsettings.json` inside `Resources/Raw/` +2. Set its **Build Action** to `MauiAsset` +3. Add your connection string: + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=127.0.0.1;Database=Frymaster;User Id=sa;Password=YourSecurePassword;TrustServerCertificate=True;" + } +} +``` + +> 🔒 **Security Note:** The app contains a hardcoded fallback connection string in `MauiProgram.cs`. **Never deploy to production without providing `appsettings.json`**, as the fallback uses plain-text credentials and will log a warning. + +--- + +## 🛠️ Building & Running + +### ✅ Using CLI +```bash +# Restore dependencies +dotnet restore + +# Build +dotnet build -c Release + +# Run (Windows Desktop default) +dotnet run -f net8.0-windows10.0.19041.0 +``` + +### ✅ Using Visual Studio +1. Open `FrymasterBadgeApp.csproj` or `.sln` +2. Select your target framework in the toolbar (e.g., `net8.0-windows10.0.19041.0`) +3. Press `F5` or click ▶ Run +4. The app will automatically restore NuGet packages and launch. + +--- + +## 🚀 First Run & Navigation Flow + +1. **Initial Setup Tab**: If no companies exist in the database, the app shows a `Setup` tab. Click it to add your first company via `CompanyPage`. +2. **Dynamic Tabs**: Once companies are added, each gets its own tab in the bottom `TabBar`. Tabs are generated dynamically at runtime. +3. **Employee Management**: Click a company tab → select/search employees → view/edit badge preview → capture/update photos → print. +4. **Settings**: Use the top-right `⚙️` icon to configure storage paths (`C:\FrymasterData\...` defaults) and UI theme (`System`/`Light`/`Dark`). +5. **Company Management**: Use the `🏢` icon in `AppShell` to manage company records and upload logos. + +--- + +## 📂 Storage & Paths + +| Setting | Default | Notes | +|---------|---------|-------| +| Photos | `C:\FrymasterData\photos` | Stores employee JPGs (`{recordNum}_{ticks}.jpg`) | +| Logos | `C:\FrymasterData\logos` | Stores company logo images | +| Images | `C:\FrymasterData\images` | Stores guest/default badge assets | +| Logs | `%LOCALAPPDATA%` | Rotates daily, max 5MB/file, 30-day auto-cleanup | + +> 🌍 **Cross-Platform Note:** The `C:\...` defaults are Windows-specific. On macOS/Linux, change paths immediately via the **Settings Page** to avoid `UnauthorizedAccessException` or missing directory errors. + +--- + +## 🐛 Troubleshooting + +| Issue | Solution | +|-------|----------| +| `SqlService` connection fails | Verify SQL Server is running, check `appsettings.json`, ensure `TrustServerCertificate=True` | +| App crashes on startup | Check `FrymasterBadgeApp_*.log` in `%LOCALAPPDATA%` for DI or XAML errors | +| Missing tabs / "Loading..." stuck | Ensure at least one company exists in `dbo.Companies` | +| Print fails / `PrinterSettings` empty | Ensure desktop target is selected. Mobile platforms don't expose `InstalledPrinters` | +| `FolderPicker` or `Toast` crashes | Wrapped in try/catch. Intentionally disables auto-navigation after save to prevent Shell crashes | +| Missing barcode font | Ensure `LibreBarcode39-Regular.ttf` is in `Resources/Fonts` with `Build Action: MauiFont` | + +--- + +## 📜 Logging & Diagnostics + +- **Location:** `%LOCALAPPDATA%\FrymasterBadgeApp_YYYY-MM-DD.log` +- **Rotation:** Files >5MB are renamed to `.old` (previous `.old` is deleted) +- **Cleanup:** `AppLogger.CleanupOldLogs()` deletes logs older than 30 days +- **Viewing:** Open with any text editor or run: + ```powershell + Get-ChildItem $env:LOCALAPPDATA\FrymasterBadgeApp_*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Invoke-Item + ``` + +--- + +## 🤝 Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Commit changes (`git commit -m "Add your feature"`) +4. Push to the branch (`git push origin feature/your-feature`) +5. Open a Pull Request + +--- + +## 📄 License + +Property of Frymaster + +--- + +> 💡 **Tip:** Run `dotnet workload restore` before first build if you encounter missing MAUI SDK errors. Ensure your `.csproj` targets a supported Windows SDK version (`net8.0-windows10.0.19041.0` or higher). \ No newline at end of file diff --git a/Resources/AppIcon/appicon.svg b/Resources/AppIcon/appicon.svg index 9d63b65..103e01f 100644 --- a/Resources/AppIcon/appicon.svg +++ b/Resources/AppIcon/appicon.svg @@ -1,4 +1,113 @@ - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/Resources/Fonts/LibreBarcode39-Regular.ttf b/Resources/Fonts/LibreBarcode39-Regular.ttf new file mode 100644 index 0000000..73fd0ac Binary files /dev/null and b/Resources/Fonts/LibreBarcode39-Regular.ttf differ diff --git a/Resources/Raw/Guest.jpg b/Resources/Raw/Guest.jpg new file mode 100644 index 0000000..5ab55ea Binary files /dev/null and b/Resources/Raw/Guest.jpg differ diff --git a/Resources/Raw/WelBilt.jpg b/Resources/Raw/WelBilt.jpg new file mode 100644 index 0000000..fc4a610 Binary files /dev/null and b/Resources/Raw/WelBilt.jpg differ diff --git a/Resources/Raw/appsettings.json b/Resources/Raw/appsettings.json index 9e623d5..2e6a282 100644 --- a/Resources/Raw/appsettings.json +++ b/Resources/Raw/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Server=ZABEASTPORTABLE\\SQLEXPRESS01;Database=FrymasterBadges;Trusted_Connection=True;TrustServerCertificate=True;" + "DefaultConnection": "Data Source=FRYDB003V\\FRYSQL1;Initial Catalog=HRBadge;Integrated Security=True;Persist Security Info=False;Pooling=False;MultipleActiveResultSets=False;Encrypt=False;TrustServerCertificate=False;Application Name='Badge Printer';Command Timeout=0" }, "Logging": { "LogLevel": { diff --git a/Resources/Raw/appsettings.json.bac b/Resources/Raw/appsettings.json.bac new file mode 100644 index 0000000..1a10879 --- /dev/null +++ b/Resources/Raw/appsettings.json.bac @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=ZABEASTPORTABLE\\SQLEXPRESS01;Database=FrymasterBadges;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/Resources/Styles/Colors.xaml b/Resources/Styles/Colors.xaml index 7ac8968..cad76e0 100644 --- a/Resources/Styles/Colors.xaml +++ b/Resources/Styles/Colors.xaml @@ -1,27 +1,33 @@ - - - #2563EB - #1E40AF - #64748B - #F1F5F9 + - #10B981 - #EF4444 - #F59E0B - #3B82F6 - #F9FAFB - #111827 - #000000 - #FFFFFF - #F3F4F6 - #E5E7EB - #6B7280 - #111827 + #2563EB + #64748B + #10B981 + #EF4444 + #3ABFF8 + #FFFFFF + #000000 + #F3F4F6 + #6B7280 + #2D3748 - #FFFFFF - #2563EB - + #F9FAFB + #111827 + #FFFFFF + #E5E7EB + #F0F0F0 + + #0F172A + #FFFFFF + #111827 + #374151 + #1F2937 + + #F9FAFB + #FFFFFF + #E5E7EB + #111827 + + \ No newline at end of file diff --git a/Resources/Styles/Styles.xaml b/Resources/Styles/Styles.xaml index 00db308..9501f65 100644 --- a/Resources/Styles/Styles.xaml +++ b/Resources/Styles/Styles.xaml @@ -1,32 +1,31 @@ - - - + - + - + + + + + + + - diff --git a/Services/PrinterService.cs b/Services/PrinterService.cs index 1eac8d2..d5c638f 100644 --- a/Services/PrinterService.cs +++ b/Services/PrinterService.cs @@ -1,234 +1,107 @@ -using System.Text; -using SkiaSharp; -using Zebra.Sdk.Card.Containers; -using Zebra.Sdk.Card.Enumerations; -using Zebra.Sdk.Card.Graphics; -using Zebra.Sdk.Card.Printer; -using Zebra.Sdk.Comm; -using Zebra.Sdk.Printer.Discovery; +using System.Drawing; +// We move the usings outside the IF for the Language Server, +// but keep the SupportedOSPlatform attribute to satisfy the linker. +using System.Drawing.Printing; +using System.IO; +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] namespace FrymasterBadgeApp.Services; +[SupportedOSPlatform("windows")] public class PrinterService { - public async Task PrintBadge( - Dictionary emp, - Dictionary comp, - string ip - ) + public PrinterService() { + AppLogger.Info("PrinterService: Constructor started."); try { - using var frontBitmap = GenerateFrontSide(emp, comp); - using var backBitmap = GenerateBackSide(emp, comp); - - string frontBase64 = ConvertBitmapToBase64(frontBitmap); - string backBase64 = ConvertBitmapToBase64(backBitmap); - - var (ok, error) = await PrintBadgeImages(frontBase64, backBase64, ip); - return ok; + var test = System.Drawing.Color.White; + AppLogger.Info("PrinterService: GDI+ Compatibility check passed."); } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Print Engine Error: {ex.Message}"); - return false; + AppLogger.Error("PrinterService: GDI+ Compatibility check FAILED.", ex); } } - private SKBitmap GenerateFrontSide( - Dictionary emp, - Dictionary comp - ) + public void PrintBase64Badge(string frontBase64, string backBase64, string printerName) { - var bitmap = new SKBitmap(648, 1016); - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.White); - - using var paint = new SKPaint { IsAntialias = true }; - using var font = new SKFont(SKTypeface.FromFamilyName("Arial"), 40); - - // 1. Draw Logo - string logoPath = comp.InternalSafeGet("logo"); - if (File.Exists(logoPath)) - { - using var logo = SKBitmap.Decode(logoPath); - canvas.DrawBitmap(logo, SKRect.Create(224, 40, 200, 80)); - } - - // 2. Draw Photo - string photoPath = emp.InternalSafeGet("ImagePath"); - if (File.Exists(photoPath)) - { - using var photo = SKBitmap.Decode(photoPath); - canvas.DrawBitmap(photo, SKRect.Create(149, 160, 350, 350)); - } - - // 3. Draw First Name (Red) - paint.Color = SKColors.Red; - font.Size = 70; - font.Embolden = true; - string firstName = emp.InternalSafeGet("Data2").Split(' ')[0].ToUpper(); - canvas.DrawText(firstName, 324, 600, SKTextAlign.Center, font, paint); - - // 4. Draw Full Name - paint.Color = SKColors.Black; - font.Size = 40; - font.Embolden = false; - canvas.DrawText(emp.InternalSafeGet("Data2"), 324, 660, SKTextAlign.Center, font, paint); - - // 5. Draw Barcode Text - font.Size = 80; - canvas.DrawText( - $"*{emp.InternalSafeGet("Data9")}*", - 324, - 850, - SKTextAlign.Center, - font, - paint - ); - - return bitmap; - } - - private SKBitmap GenerateBackSide( - Dictionary emp, - Dictionary comp - ) - { - var bitmap = new SKBitmap(1016, 648); - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.White); - - using var paint = new SKPaint { IsAntialias = true, Color = SKColors.Black }; - using var font = new SKFont(SKTypeface.FromFamilyName("Arial"), 25); - - canvas.DrawText( - $"This badge is the property of {comp.InternalSafeGet("Name")} L.L.C.", - 508, - 100, - SKTextAlign.Center, - font, - paint - ); - - canvas.DrawText(emp.InternalSafeGet("Data1"), 100, 200, SKTextAlign.Left, font, paint); - canvas.DrawText(emp.InternalSafeGet("Data10"), 100, 240, SKTextAlign.Left, font, paint); - - canvas.DrawText(comp.InternalSafeGet("Address"), 500, 200, SKTextAlign.Left, font, paint); - canvas.DrawText( - $"{comp.InternalSafeGet("City")}, {comp.InternalSafeGet("State")} {comp.InternalSafeGet("Zip")}", - 500, - 240, - SKTextAlign.Left, - font, - paint - ); - - font.Size = 80; - canvas.DrawText( - $"*{emp.InternalSafeGet("Data9")}*", - 508, - 500, - SKTextAlign.Center, - font, - paint - ); - - return bitmap; - } - - private string ConvertBitmapToBase64(SKBitmap bitmap) - { - using var image = SKImage.FromBitmap(bitmap); - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - return Convert.ToBase64String(data.ToArray()); - } - - public async Task> DiscoverPrinters() - { - return await Task.Run(() => - { - List list = new(); - var handler = new InternalStatusDiscoveryHandler(); - NetworkDiscoverer.FindPrinters(handler); - int wait = 0; - while (!handler.IsDiscoveryComplete && wait < 5000) - { - Thread.Sleep(200); - wait += 200; - } - foreach (var p in handler.DiscoveredPrinters) - list.Add(p.Address); - return list; - }); - } - - public async Task<(bool ok, string error)> PrintBadgeImages( - string front, - string back, - string ip - ) - { - if (string.IsNullOrEmpty(ip)) - return (false, "No IP"); - Connection connection = new TcpConnection(ip, TcpConnection.DEFAULT_ZPL_TCP_PORT); - Zebra.Sdk.Card.Printer.ZebraCardPrinter? cardPrinter = null; + // Wrap the actual logic in the WINDOWS check so it doesn't try to compile on other platforms +#if WINDOWS try { - connection.Open(); - cardPrinter = ZebraCardPrinterFactory.GetInstance(connection); - using ZebraCardGraphics g = new ZebraCardGraphics(cardPrinter); - List info = new(); - if (!string.IsNullOrEmpty(front)) + AppLogger.Info($"PrinterService: Attempting to print to {printerName}"); + + using (PrintDocument pd = new PrintDocument()) { - g.DrawImage( - Convert.FromBase64String(front), - 0, - 0, - 0, - 0, - (Zebra.Sdk.Card.Graphics.Enumerations.RotationType)0 - ); - info.Add( - new Zebra.Sdk.Card.Containers.GraphicsInfo + pd.PrinterSettings.PrinterName = printerName; + pd.DefaultPageSettings.Landscape = true; + pd.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); + + bool frontPrinted = false; + + pd.PrintPage += (sender, e) => + { + try { - Side = Zebra.Sdk.Card.Enumerations.CardSide.Front, - PrintType = Zebra.Sdk.Card.Enumerations.PrintType.Color, - GraphicData = g.CreateImage(), + string currentB64 = !frontPrinted ? frontBase64 : backBase64; + + using (var ms = new MemoryStream(Convert.FromBase64String(currentB64))) + using (var img = System.Drawing.Image.FromStream(ms)) + { + if (img.Height > img.Width) + { + e.Graphics.TranslateTransform(e.PageBounds.Width, 0); + e.Graphics.RotateTransform(90); + e.Graphics.DrawImage( + img, + 0, + 0, + e.PageBounds.Height, + e.PageBounds.Width + ); + } + else + { + e.Graphics.DrawImage( + img, + 0, + 0, + e.PageBounds.Width, + e.PageBounds.Height + ); + } + } + + if (!frontPrinted && !string.IsNullOrEmpty(backBase64)) + { + frontPrinted = true; + e.HasMorePages = true; + } + else + { + e.HasMorePages = false; + } } - ); + catch (Exception ex) + { + AppLogger.Error("PrinterService: Error during PrintPage event", ex); + e.HasMorePages = false; + } + }; + + pd.Print(); + AppLogger.Info("PrinterService: Print job sent to spooler."); } - cardPrinter.Print(1, info); - return (true, ""); } catch (Exception ex) { - return (false, ex.Message); - } - finally - { - cardPrinter?.Destroy(); - if (connection.Connected) - connection.Close(); + AppLogger.Error("PrinterService: Critical failure in PrintBase64Badge", ex); } +#else + AppLogger.Warn("PrinterService: PrintBase64Badge called on non-Windows platform."); +#endif } } - -// Fixed missing classes and helpers -public class InternalStatusDiscoveryHandler : DiscoveryHandler -{ - public List DiscoveredPrinters { get; } = new(); - public bool IsDiscoveryComplete { get; private set; } = false; - - public void FoundPrinter(DiscoveredPrinter printer) => DiscoveredPrinters.Add(printer); - - public void DiscoveryFinished() => IsDiscoveryComplete = true; - - public void DiscoveryError(string message) => IsDiscoveryComplete = true; -} - -internal static class PrinterExtensions -{ - public static string InternalSafeGet(this Dictionary dict, string key) => - dict.TryGetValue(key, out var val) && val != null ? val.ToString()! : ""; -} diff --git a/Services/SqlService.cs b/Services/SqlService.cs index f5f42ca..67573a7 100644 --- a/Services/SqlService.cs +++ b/Services/SqlService.cs @@ -8,43 +8,94 @@ public class SqlService { private readonly string _connectionString; - public SqlService(IConfiguration configuration) + // Change constructor to accept string instead of IConfiguration + public SqlService(string connectionString) { - // Pulls the "Default" string from your JSON structure - _connectionString = - configuration.GetConnectionString("Default") - ?? throw new Exception("Connection string 'Default' not found in appsettings.json"); + _connectionString = connectionString; + + if (string.IsNullOrEmpty(_connectionString)) + { + AppLogger.Warn("SqlService: Initialized with an EMPTY connection string."); + } } public List> Query(string sql, SqlParameter[]? parameters = null) { var rows = new List>(); - using var conn = new SqlConnection(_connectionString); - using var cmd = new SqlCommand(sql, conn); - if (parameters != null) - cmd.Parameters.AddRange(parameters); - conn.Open(); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + if (string.IsNullOrEmpty(_connectionString)) { - var row = new Dictionary(); - for (int i = 0; i < reader.FieldCount; i++) - { - row[reader.GetName(i)] = reader.GetValue(i); - } - rows.Add(row); + AppLogger.Warn( + $"SqlService: Skipping Query because connection string is missing. SQL: {sql}" + ); + return rows; } + + try + { + using var conn = new SqlConnection(_connectionString); + using var cmd = new SqlCommand(sql, conn); + + if (parameters != null) + cmd.Parameters.AddRange(parameters); + + AppLogger.Debug($"SqlService: Executing Query: {sql}"); + conn.Open(); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var row = new Dictionary(); + for (int i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.GetValue(i); + } + rows.Add(row); + } + AppLogger.Info($"SqlService: Query returned {rows.Count} rows."); + } + catch (Exception ex) + { + AppLogger.Error($"SqlService: Query execution failed for: {sql}", ex); + // We re-throw here because this happens after the app has already started + throw; + } + return rows; } public void Execute(string sql, SqlParameter[]? parameters = null) { - using var conn = new SqlConnection(_connectionString); - using var cmd = new SqlCommand(sql, conn); - if (parameters != null) - cmd.Parameters.AddRange(parameters); - conn.Open(); - cmd.ExecuteNonQuery(); + if (string.IsNullOrEmpty(_connectionString)) + { + AppLogger.Warn($"SqlService: Skipping Execute because connection string is missing."); + return; + } + + try + { + using var conn = new SqlConnection(_connectionString); + using var cmd = new SqlCommand(sql, conn); + + if (parameters != null) + { + // Clear any previous ownership just in case + cmd.Parameters.Clear(); + cmd.Parameters.AddRange(parameters); + } + + AppLogger.Debug($"SqlService: Executing Command: {sql}"); + conn.Open(); + int affected = cmd.ExecuteNonQuery(); + AppLogger.Info($"SqlService: Execute finished. Rows affected: {affected}"); + + // IMPORTANT: Clear parameters after execution so they can be reused if needed + cmd.Parameters.Clear(); + } + catch (Exception ex) + { + AppLogger.Error($"SqlService: Execute failed for: {sql}", ex); + throw; // This will trigger the DisplayAlert in your Page + } } } diff --git a/SettingsPage.xaml b/SettingsPage.xaml new file mode 100644 index 0000000..a0b9d6e --- /dev/null +++ b/SettingsPage.xaml @@ -0,0 +1,130 @@ + + + + + +