Removed large files FiX

master
Jeremy Hayes 2026-04-12 18:01:59 -05:00
parent de280dab2d
commit 6e2b838539
44 changed files with 4596 additions and 1384 deletions

26
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

41
.vscode/tasks.json vendored Normal file
View File

@ -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"
}
]
}

View File

@ -1,13 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="UTF-8" ?>
<Application
x:Class="FrymasterBadgeApp.App"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="FrymasterBadgeApp.App"
>
xmlns:local="clr-namespace:FrymasterBadgeApp">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@ -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<Microsoft.Maui.Controls.PlatformConfiguration.Windows>()
.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;
}
/// <summary>
/// Applies the saved theme preference to the app.
/// If the preference could not be applied, a warning is logged.
/// </summary>
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}");
}
}
/// <summary>
/// Creates a new instance of the required AppShell service and
/// opens a new window with the specified content.
/// </summary>
protected override Window CreateWindow(IActivationState? activationState)
{
Debug.WriteLine("CreateWindow called");
return new Window(MainPage!);
AppLogger.Info("App: CreateWindow entered");
try
{
var shell = _serviceProvider.GetRequiredService<AppShell>();
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,
},
}
}
}
);
}
}
}

110
App.xaml.cs.md Normal file
View File

@ -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

79
App.xaml.md Normal file
View File

@ -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
<?xml version="1.0" encoding="UTF-8" ?>
<Application
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">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
\`\`\`
## Key Elements
### Root Element
- **`<Application>`** — 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 `<Application.Resources>`:
- 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.

105
AppLogger.cs Normal file
View File

@ -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);
/// <summary>
/// Logs an error to the application log, including the given message.
/// <param name="message">The message to be logged.</param name="ex">The exception associated with the error, or null if none.</summary>
public static void Error(string message, Exception? ex = null)
{
string full = ex != null ? $"{message}\nException: {ex}" : message;
Log("ERROR", full);
}
/// <summary>
/// Logs a a instructions to the application log, including the given message.
/// <param name="message">The message to be logged.</summary>
public static void Debug(string message)
{
#if DEBUG
Log("DEBUG", message);
#endif
}
/// <summary>
/// Logs a message to the application log, including the given message.
/// </summary>
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);
}
}
/// <summary>
/// Deletes old log files that are older than the given number of days.
/// </summary>
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 { }
}
}
}
}

View File

@ -1,15 +1,53 @@
<?xml version="1.0" encoding="utf-8" ?>
<Shell
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:Class="FrymasterBadgeApp.AppShell"
>
<TabBar x:Name="MainTabBar" />
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}">
<Shell.ToolbarItems>
<ToolbarItem
Clicked="OnSettingsClicked"
Order="Secondary"
Text="Settings" />
<ToolbarItem
Clicked="OnThemeClicked"
Order="Primary"
Text="Theme" />
<ToolbarItem
Clicked="OnManageCompaniesClicked"
Order="Secondary"
Text="Manage Companies" />
<ToolbarItem
Clicked="OnAboutClicked"
Order="Secondary"
Text="About" />
<ToolbarItem
Clicked="OnExitClicked"
Order="Secondary"
Text="Exit" />
</Shell.ToolbarItems>
<TabBar x:Name="MainTabBar" Route="MainTabBar">
<Tab x:Name="SetupTabContainer" Title="Setup">
<ShellContent
x:Name="SetupTab"
Title="Setup"
ContentTemplate="{DataTemplate pages:CompanyPage}"
/>
Route="CompanyPage" />
</Tab>
</TabBar>
</Shell>

View File

@ -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)
/// <summary>
/// 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.
/// </summary>
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();
}
}
/// <summary>
/// Loads all companies in the background and then switches to the main thread to update the UI.
/// </summary>
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<CompanyPage>()
),
}
);
}
else
{
foreach (var company in companies)
{
string companyName = company.GetValueOrDefault("Name")?.ToString() ?? "Unknown";
var employeePage = new EmployeePage(_db, _printerService, company)
var companyId = company.GetValueOrDefault("ID")?.ToString() ?? "0";
MainTabBar.Items.Add(
new ShellContent
{
Title = companyName,
};
var manageBtn = new ToolbarItem
{
Text = "Manage Companies",
Order = ToolbarItemOrder.Primary,
Command = new Command(async () =>
await employeePage.Navigation.PushAsync(new CompanyPage(_db))
Title = company.GetValueOrDefault("Name")?.ToString() ?? "Unknown",
Route = $"EmployeePage_{companyId}",
ContentTemplate = new DataTemplate(() =>
new EmployeePage(_db, _printerService, company)
),
};
employeePage.ToolbarItems.Add(manageBtn);
var tab = new Tab { Title = companyName };
tab.Items.Add(new ShellContent { Content = employeePage });
MainTabBar.Items.Add(tab);
}
);
}
}
// 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");
}
}
/// <summary>
/// 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));
/// <summary>
/// Open a simple action sheet to choose theme globally
/// </summary>
/// <param name="sender">The object that triggered the event</param>
/// <param name="e">The event arguments</param>
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();
/// <summary>
/// Open a simple action sheet to show the app's about page
/// </summary>
/// <param name="sender">The object that triggered the event</param>
/// <param name="e">The event arguments</param>
private async void OnAboutClicked(object sender, EventArgs e) =>
await DisplayAlert("About", "Frymaster Badge App v1.0", "OK");
}

Binary file not shown.

View File

@ -1,124 +1,168 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="FrymasterBadgeApp.CompanyPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="FrymasterBadgeApp.CompanyPage"
Title="Manage Companies"
BackgroundColor="#f1f5f9"
>
<Grid ColumnDefinitions="350,*" Padding="20">
<!-- Left: Company List -->
<Frame
BackgroundColor="White"
BorderColor="#e2e8f0"
CornerRadius="12"
HasShadow="True"
Padding="0"
>
<VerticalStackLayout>
Title="Company Settings">
<Grid ColumnDefinitions="350, *">
<Border
Grid.Column="0"
Margin="10"
Style="{StaticResource CardStyle}">
<VerticalStackLayout Padding="10" Spacing="10">
<Label
Text="Companies"
FontSize="18"
FontAttributes="Bold"
Margin="15"
TextColor="#1e293b"
/>
FontSize="18"
Text="Registered Companies" />
<Button
BackgroundColor="{StaticResource Success}"
Clicked="OnAddNewClicked"
Text="+ Add New"
TextColor="White" />
<CollectionView
x:Name="CompanyList"
SelectionMode="Single"
SelectionChanged="OnCompanySelected"
Margin="10"
>
SelectionChanged="OnCompanySelectionChanged"
SelectionMode="Single">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame BackgroundColor="#f8fafc" CornerRadius="8" Padding="12" Margin="5">
<Label Text="{Binding [Name]}" FontSize="16" TextColor="#1e293b" />
</Frame>
<Grid Padding="10">
<Label
FontSize="16"
Text="{Binding [Name]}"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
</Frame>
</Border>
<!-- Right: Edit Form -->
<ScrollView Grid.Column="1" Margin="30,0,0,0">
<VerticalStackLayout Spacing="25" HorizontalOptions="Start" WidthRequest="700">
<Button
Text="+ Add New Company"
Clicked="OnAddNewClicked"
BackgroundColor="#10b981"
TextColor="White"
<ScrollView Grid.Column="1">
<VerticalStackLayout Padding="30" Spacing="25">
<Label
FontAttributes="Bold"
CornerRadius="12"
HeightRequest="50"
/>
FontSize="24"
Text="Edit Company Details"
TextColor="{StaticResource Primary}" />
<Frame BackgroundColor="White" CornerRadius="16" HasShadow="True" Padding="30">
<Border Padding="25" Style="{StaticResource CardStyle}">
<VerticalStackLayout Spacing="20">
<Label Text="Company Logo" FontSize="16" FontAttributes="Bold" TextColor="#1e293b" />
<HorizontalStackLayout HorizontalOptions="Center" Spacing="20">
<Frame BackgroundColor="#f1f5f9" CornerRadius="8" Padding="0">
<Image
x:Name="LogoPreview"
HeightRequest="120"
WidthRequest="240"
Aspect="AspectFit"
/>
</Frame>
</HorizontalStackLayout>
<Grid ColumnDefinitions="100, *" ColumnSpacing="15">
<VerticalStackLayout Grid.Column="1" Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="Company Name"
TextColor="{StaticResource Primary}" />
<Entry x:Name="CompName" Placeholder="Required" />
</VerticalStackLayout>
</Grid>
<VerticalStackLayout Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="Street Address"
TextColor="{StaticResource Primary}" />
<Entry x:Name="CompAddress" Placeholder="123 Main St" />
</VerticalStackLayout>
<Grid ColumnDefinitions="*, 100, 150" ColumnSpacing="15">
<VerticalStackLayout Grid.Column="0" Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="City"
TextColor="{StaticResource Primary}" />
<Entry x:Name="CompCity" Placeholder="City Name" />
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1" Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="State"
TextColor="{StaticResource Primary}" />
<Entry
x:Name="CompState"
HorizontalTextAlignment="Center"
MaxLength="2"
Placeholder="ST" />
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="2" Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="Zip Code"
TextColor="{StaticResource Primary}" />
<Entry
x:Name="CompZip"
Keyboard="Numeric"
Placeholder="12345" />
</VerticalStackLayout>
</Grid>
<VerticalStackLayout Spacing="10">
<Label
FontAttributes="Bold"
FontSize="12"
Text="Company Logo"
TextColor="{StaticResource Primary}" />
<Grid ColumnDefinitions="Auto, *" ColumnSpacing="20">
<Border
Grid.Column="0"
Padding="5"
HeightRequest="100"
Style="{StaticResource CardStyle}"
WidthRequest="100">
<Image x:Name="LogoPreview" Aspect="AspectFit" />
</Border>
<VerticalStackLayout
Grid.Column="1"
Spacing="10"
VerticalOptions="Center">
<Label
x:Name="LogoPathLabel"
FontSize="11"
Text="No logo selected"
TextColor="{StaticResource Gray500}" />
<Button
Text="Choose Logo Image"
BackgroundColor="{StaticResource Secondary}"
Clicked="OnSelectLogoClicked"
BackgroundColor="#64748b"
TextColor="White"
CornerRadius="10"
/>
<VerticalStackLayout Spacing="15">
<Label Text="Company Name" TextColor="#475569" />
<Entry x:Name="CompName" Placeholder="Enter name" />
<Label Text="Street Address" TextColor="#475569" />
<Entry x:Name="CompAddress" Placeholder="Enter address" />
<Grid ColumnDefinitions="*,100,150" ColumnSpacing="15">
<VerticalStackLayout Grid.Column="0">
<Label Text="City" TextColor="#475569" />
<Entry x:Name="CompCity" Placeholder="City" />
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1">
<Label Text="State" TextColor="#475569" />
<Entry x:Name="CompState" Placeholder="ST" />
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="2">
<Label Text="Zip" TextColor="#475569" />
<Entry x:Name="CompZip" Placeholder="Zip" />
HorizontalOptions="Start"
Text="Browse for Logo"
TextColor="White" />
</VerticalStackLayout>
</Grid>
</VerticalStackLayout>
<Grid ColumnDefinitions="*,*" ColumnSpacing="20">
<BoxView
Margin="0,10"
HeightRequest="1"
Color="{StaticResource Gray500}" />
<HorizontalStackLayout HorizontalOptions="End" Spacing="15">
<Button
Text="Save Company"
Clicked="OnSaveClicked"
BackgroundColor="#2563eb"
TextColor="White"
FontAttributes="Bold"
CornerRadius="12"
/>
<Button
Text="Delete Company"
x:Name="DeleteBtn"
BackgroundColor="{StaticResource Danger}"
Clicked="OnDeleteClicked"
BackgroundColor="#ef4444"
Text="Delete Company"
TextColor="White"
WidthRequest="150" />
<Button
BackgroundColor="{StaticResource Success}"
Clicked="OnSaveClicked"
FontAttributes="Bold"
CornerRadius="12"
/>
</Grid>
Text="Save Changes"
TextColor="White"
WidthRequest="200" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</Border>
</VerticalStackLayout>
</ScrollView>
</Grid>

View File

@ -1,5 +1,7 @@
using System.Diagnostics;
using FrymasterBadgeApp.Services;
using Microsoft.Data.SqlClient;
using Microsoft.Maui.Storage;
namespace FrymasterBadgeApp;
@ -8,102 +10,293 @@ public partial class CompanyPage : ContentPage
private readonly SqlService _db;
private Dictionary<string, object>? _selectedCompany;
private string _tempLogoPath = "";
private const string LogoPathKey = "LogoBasePath";
/// <summary>
/// Constructor for CompanyPage.
/// of type ContentPage.
/// the main page for viewing and editing company information.
/// <param name="db">The SQLService object used to access the database.</param>
public CompanyPage(SqlService db)
{
InitializeComponent();
_db = db;
Shell.SetBackButtonBehavior(
this,
new BackButtonBehavior
{
TextOverride = "Back",
Command = new Command(async () => await Shell.Current.GoToAsync("..")),
}
);
LoadCompanies();
}
/// <summary>
/// Returns the path to the directory used to store company logos.
/// </summary>
/// <returns>The path to the directory used to store company logos.</returns>
private string GetLogoDirectory() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"FrymasterBadgeApp",
"Logos"
);
Preferences.Default.Get(LogoPathKey, @"C:\FrymasterData\logos");
private void LoadCompanies() =>
CompanyList.ItemsSource = _db.Query("SELECT * FROM Companies", null);
private void OnCompanySelected(object sender, SelectedItemChangedEventArgs e)
/// <summary>
/// Loads the list of companies from the database.
/// the results are ordered by company name (ascending).
/// and displays the companies are stored in the CompanyList.
/// </summary>
private void LoadCompanies()
{
_selectedCompany = e.SelectedItem as Dictionary<string, object>;
if (_selectedCompany == null)
return;
CompName.Text = _selectedCompany["Name"]?.ToString();
CompAddress.Text = _selectedCompany["Address"]?.ToString();
_tempLogoPath = _selectedCompany.GetValueOrDefault("logo")?.ToString() ?? "";
LogoPreview.Source = File.Exists(_tempLogoPath)
? ImageSource.FromFile(_tempLogoPath)
: null;
try
{
var companies = _db.Query("SELECT * FROM Companies ORDER BY Name ASC", null);
CompanyList.ItemsSource = companies;
}
catch (Exception ex)
{
AppLogger.Error("Failed to load companies", ex);
DisplayAlert("Error", "Failed to load companies.", "OK");
}
}
/// <summary>
/// Called when the selection in the companies list changes.
/// the selection is used to update the company name, address, city, state, zip code, and delete button visibility.
/// </summary>
private void OnCompanySelectionChanged(object sender, SelectionChangedEventArgs e)
{
var selected = e.CurrentSelection.FirstOrDefault() as Dictionary<string, object>;
if (selected == null)
{
ClearFields();
return;
}
_selectedCompany = selected;
MainThread.BeginInvokeOnMainThread(async () =>
{
CompName.Text = GetValue(selected, "Name");
CompAddress.Text = GetValue(selected, "Address");
CompCity.Text = GetValue(selected, "City");
CompState.Text = GetValue(selected, "State");
CompZip.Text = GetValue(selected, "Zip");
DeleteBtn.IsVisible = true;
LogoPreview.Source = null;
_tempLogoPath = "";
// Get stored filename only
string storedFileName = GetValue(selected, "logo");
if (!string.IsNullOrWhiteSpace(storedFileName))
{
string logoDir = GetLogoDirectory();
string fullPath = Path.Combine(logoDir, storedFileName);
if (File.Exists(fullPath))
{
_tempLogoPath = fullPath;
LogoPreview.Source = ImageSource.FromFile(fullPath);
AppLogger.Info($"Logo loaded for company: {fullPath}");
}
else
{
AppLogger.Warning($"Logo file missing: {fullPath}");
}
}
});
}
/// <summary>
/// Returns the value of the given key from the dictionary, trimmed of whitespace.
/// If the key does not exist, an empty string is returned.
/// </summary>
/// <param name="dict">The dictionary to search.</param>
/// <param name="key">The key to search for.</param>
/// <returns>The value of the given key, trimmed of whitespace, or an empty string if the key does not exist.</returns>
private string GetValue(Dictionary<string, object> dict, string key)
{
var actualKey = dict.Keys.FirstOrDefault(k =>
k.Equals(key, StringComparison.OrdinalIgnoreCase)
);
return actualKey != null ? dict[actualKey]?.ToString()?.Trim() ?? "" : "";
}
/// <summary>
/// Called when the user selects a logo image from the file picker.
/// </summary>
/// <param name="sender">The object that triggered the event</param>
/// <param name="e">The event data which contains information about the event</param>
private async void OnSelectLogoClicked(object sender, EventArgs e)
{
try
{
var result = await FilePicker.Default.PickAsync(
new PickOptions { PickerTitle = "Select Logo", FileTypes = FilePickerFileType.Images }
new PickOptions
{
PickerTitle = "Select Company Logo",
FileTypes = FilePickerFileType.Images,
}
);
if (result != null)
{
_tempLogoPath = result.FullPath;
LogoPreview.Source = ImageSource.FromFile(_tempLogoPath);
}
}
private void OnSaveClicked(object sender, EventArgs e)
catch (Exception ex)
{
await DisplayAlert("Error", $"Failed to select logo: {ex.Message}", "OK");
}
}
/// <summary>
/// Save the selected company to the database.
/// </summary>
/// <param name="sender">The object that invoked this method.</param>
/// <param name="e">The event data for this method.</param>
private async void OnSaveClicked(object sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(CompName.Text))
{
await DisplayAlert("Error", "Company Name is required", "OK");
return;
}
string logoDir = GetLogoDirectory();
if (!Directory.Exists(logoDir))
Directory.CreateDirectory(logoDir);
string finalLogoPath = _tempLogoPath;
if (!string.IsNullOrEmpty(_tempLogoPath) && !_tempLogoPath.Contains(logoDir))
{
string dest = Path.Combine(logoDir, Path.GetFileName(_tempLogoPath));
File.Copy(_tempLogoPath, dest, true);
finalLogoPath = dest;
try
{
Directory.CreateDirectory(logoDir);
}
catch
{
await DisplayAlert("Error", "Cannot create logo directory.", "OK");
return;
}
}
string logoFileNameToSave = ""; // will be stored in DB
if (!string.IsNullOrEmpty(_tempLogoPath))
{
try
{
string originalFileName = Path.GetFileName(_tempLogoPath);
string extension = Path.GetExtension(originalFileName);
string baseName = Path.GetFileNameWithoutExtension(originalFileName);
// Avoid overwrites: add timestamp if file already exists
string destFileName = originalFileName;
string destPath = Path.Combine(logoDir, destFileName);
if (File.Exists(destPath))
{
destFileName = $"{baseName}_{DateTime.Now:yyyyMMdd_HHmmss}{extension}";
destPath = Path.Combine(logoDir, destFileName);
}
File.Copy(_tempLogoPath, destPath, true);
logoFileNameToSave = destFileName; // only filename saved to DB
_tempLogoPath = destPath; // update local temp for preview
AppLogger.Info($"Logo saved as: {destPath}");
}
catch (Exception ex)
{
AppLogger.Error("Failed to copy logo", ex);
await DisplayAlert("Error", "Failed to save logo file.", "OK");
return;
}
}
try
{
var parameters = new List<SqlParameter>
{
new("@n", CompName.Text ?? ""),
new("@a", CompAddress.Text ?? ""),
new("@c", CompCity.Text ?? ""),
new("@s", CompState.Text ?? ""),
new("@z", CompZip.Text ?? ""),
new("@l", logoFileNameToSave), // ← only filename
};
if (_selectedCompany == null)
{
_db.Execute(
"INSERT INTO Companies (Name, Address, logo) VALUES (@n, @a, @l)",
new SqlParameter[]
{
new("@n", CompName.Text),
new("@a", CompAddress.Text),
new("@l", finalLogoPath),
}
"INSERT INTO Companies (Name, Address, City, State, Zip, logo) VALUES (@n, @a, @c, @s, @z, @l)",
parameters.ToArray()
);
await DisplayAlert("Success", "Company added.", "OK");
}
else
{
parameters.Add(new("@id", _selectedCompany["ID"]));
_db.Execute(
"UPDATE Companies SET Name=@n, Address=@a, logo=@l WHERE ID=@id",
new SqlParameter[]
{
new("@n", CompName.Text),
new("@a", CompAddress.Text),
new("@l", finalLogoPath),
new("@id", _selectedCompany["ID"]),
}
"UPDATE Companies SET Name=@n, Address=@a, City=@c, State=@s, Zip=@z, logo=@l WHERE ID=@id",
parameters.ToArray()
);
}
LoadCompanies();
await DisplayAlert("Success", "Company updated.", "OK");
}
LoadCompanies();
ClearFields();
}
catch (Exception ex)
{
AppLogger.Error("Database save failed", ex);
await DisplayAlert("Database Error", ex.Message, "OK");
}
}
/// <summary>
/// Resets the UI state after clicking the "Add New" button.
/// </summary>
private void OnAddNewClicked(object sender, EventArgs e)
{
_selectedCompany = null;
CompName.Text = CompAddress.Text = "";
LogoPreview.Source = null;
CompanyList.SelectedItem = null;
ClearFields();
}
private void OnDeleteClicked(
object sender,
EventArgs e
) { /* Delete logic */
/// <summary>
/// Resets the UI state after clicking the "Add New" button or deleting a company.
/// </summary>
private void ClearFields()
{
_selectedCompany = null;
_tempLogoPath = "";
CompName.Text = CompAddress.Text = CompCity.Text = CompState.Text = CompZip.Text = "";
LogoPreview.Source = null;
DeleteBtn.IsVisible = false;
}
/// <summary>
/// Delete a company from the database.
/// </summary>
private async void OnDeleteClicked(object sender, EventArgs e)
{
if (_selectedCompany == null)
return;
if (await DisplayAlert("Confirm", $"Delete {CompName.Text}?", "Yes", "No"))
{
try
{
_db.Execute(
"DELETE FROM Companies WHERE ID=@id",
new SqlParameter[] { new("@id", _selectedCompany["ID"]) }
);
LoadCompanies();
ClearFields();
}
catch (Exception ex)
{
await DisplayAlert("Error", ex.Message, "OK");
}
}
}
}

View File

@ -0,0 +1,26 @@
using Microsoft.Maui.Controls;
namespace FrymasterBadgeApp.Converters;
public class IsNotNullConverter : IValueConverter
{
public object Convert(
object value,
Type targetType,
object parameter,
System.Globalization.CultureInfo culture
)
{
return value != null;
}
public object ConvertBack(
object value,
Type targetType,
object parameter,
System.Globalization.CultureInfo culture
)
{
throw new NotImplementedException();
}
}

49
Documentation/AppCS.md Normal file
View File

@ -0,0 +1,49 @@
# App
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `Microsoft.Maui.Controls.Application`
**Purpose:** MAUI application entry point. Handles theme persistence, dependency injection, and graceful startup error handling.
## 🔑 Fields
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `_serviceProvider` | `IServiceProvider` | `private` | Holds the DI container used to resolve services like `AppShell`. |
| `ThemePrefKey` | `const string` | `public` | Constant key (`"AppTheme"`) used to store/retrieve theme preference. Must match `SettingsPage`. |
## 🛠 Constructor
### `App(IServiceProvider serviceProvider)`
- **Access:** `public`
- **Parameters:** `serviceProvider` (`IServiceProvider`)
- **Behavior:**
1. Logs constructor start.
2. Calls `InitializeComponent()` to load XAML UI.
3. Applies saved theme via `ApplySavedTheme()`.
4. Stores the injected `IServiceProvider`.
5. Catches and logs fatal XAML initialization errors.
## 📦 Methods
### `ApplySavedTheme()`
- **Access:** `private`
- **Returns:** `void`
- **Behavior:**
1. Reads `"AppTheme"` from `Preferences.Default`. Defaults to `"System"`.
2. Maps string to `AppTheme` enum (`Light`, `Dark`, `Unspecified`).
3. Sets `Application.Current.UserAppTheme`.
4. Logs success or warns on failure.
### `CreateWindow(IActivationState? activationState)`
- **Access:** `protected override`
- **Parameters:** `activationState` (`IActivationState?`)
- **Returns:** `Window`
- **Behavior:**
1. Resolves `AppShell` via `_serviceProvider`.
2. Returns new `Window` containing resolved `AppShell`.
3. If resolution fails, returns a fallback `ContentPage` with `"Startup Failed"` title, exception message in red, and log reference. Prevents hard crash.
---
**Key Features:**
- ✅ Theme persistence across sessions
- ✅ DI-ready shell resolution
- ✅ Graceful startup failure UI
- ✅ Comprehensive initialization logging

View File

@ -0,0 +1,82 @@
# AppLogger
**Namespace:** `FrymasterBadgeApp`
**Type:** `static class`
**Purpose:** Centralized, thread-safe logging utility that writes timestamped messages to daily rotating log files, console, and debug output. Includes automatic file rotation and scheduled cleanup.
## 🔑 Fields
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `LogDirectory` | `string` | `private static readonly` | Path to `%LOCALAPPDATA%` where logs are stored. |
| `BaseLogName` | `string` | `private static readonly` | Base filename prefix (`FrymasterBadgeApp`). |
| `MaxFileSizeBytes` | `long` | `private static readonly` | Maximum log file size before rotation (`5 MB`). |
| `_lock` | `object` | `private static readonly` | Synchronization object for thread-safe file writes. |
## 🛠 Constructor
### `static AppLogger()`
- **Access:** `static`
- **Behavior:** Automatically logs a `DEBUG` message upon first access to confirm initialization.
## 📦 Methods
### `GetCurrentLogPath()`
- **Access:** `private static`
- **Returns:** `string`
- **Behavior:** Constructs a log file path using the current date in `yyyy-MM-dd` format (e.g., `...\FrymasterBadgeApp_2026-04-13.log`).
### `Info(string message)`
- **Access:** `public static`
- **Parameters:**
| Name | Type | Description |
|------|------|-------------|
| `message` | `string` | The informational text to log. |
- **Behavior:** Wrapper that calls `Log("INFO ", message)`.
### `Warn(string message)`
- **Access:** `public static`
- **Parameters:** `message` (`string`)
- **Behavior:** Logs a warning-level message (`"WARN "`).
### `Warning(string message)`
- **Access:** `public static`
- **Parameters:** `message` (`string`)
- **Behavior:** Alias for `Warn`, logs `"WARNING "` level.
### `Error(string message, Exception? ex = null)`
- **Access:** `public static`
- **Parameters:**
| Name | Type | Description |
|------|------|-------------|
| `message` | `string` | Primary error description. |
| `ex` | `Exception?` | Associated exception (optional). |
- **Behavior:** Appends `\nException: {ex}` if `ex` is provided, then logs at `"ERROR "` level.
### `Debug(string message)`
- **Access:** `public static`
- **Parameters:** `message` (`string`)
- **Behavior:** Logs at `"DEBUG"` level. Wrapped in `#if DEBUG`, so calls are compiled out in Release builds.
### `Log(string level, string message)`
- **Access:** `private static`
- **Parameters:**
| Name | Type | Description |
|------|------|-------------|
| `level` | `string` | Log level string (`INFO`, `ERROR`, etc.) |
| `message` | `string` | Log content. |
- **Behavior:**
1. Formats timestamp (`yyyy-MM-dd HH:mm:ss.fff`).
2. Acquires `_lock` for thread safety.
3. Checks if current log exceeds `MaxFileSizeBytes`. If so, renames to `.old` (deleting existing `.old` first).
4. Appends formatted line to file. Catches & logs `Console.WriteLine` if file I/O fails.
5. Writes to `Console` and `System.Diagnostics.Debug`.
### `CleanupOldLogs(int daysToKeep = 30)`
- **Access:** `public static`
- **Parameters:** `daysToKeep` (`int`, default: `30`)
- **Behavior:** Scans `LogDirectory` for `FrymasterBadgeApp_*.log`, checks `File.GetCreationTime`, and deletes expired files. Exceptions during deletion are silently caught.
---
**Notes:**
- Thread-safe via `lock (_lock)`.
- Log rotation prevents unbounded disk growth.
- Safe to call from any thread.

View File

@ -0,0 +1,60 @@
# AppShell
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `Shell`
**Purpose:** MAUI Shell container responsible for dynamic tab generation, routing, theme switching, and app-level navigation.
## 🔑 Fields
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `_db` | `SqlService` | `private readonly` | Database service. |
| `_printerService` | `PrinterService` | `private readonly` | Printing service. |
| `_serviceProvider` | `IServiceProvider` | `private readonly` | DI container for resolving pages. |
| `_isInitialized` | `bool` | `private` | Prevents duplicate company loading. |
## 🛠 Constructor
### `AppShell(SqlService db, PrinterService printerService, IServiceProvider serviceProvider)`
- **Access:** `public`
- **Behavior:** Registers routes for `SettingsPage`, `CompanyPage`, and `EmployeePage`.
## 📦 Methods
### `OnAppearing()`
- **Access:** `protected override async void`
- **Behavior:** Ensures `LoadCompaniesAsync()` runs exactly once when shell becomes visible.
### `LoadCompaniesAsync()`
- **Access:** `private async Task`
- **Behavior:**
1. Fetches companies on background thread via `Task.Run`.
2. Switches to main thread to clear `MainTabBar.Items`.
3. If no companies: adds `"Setup"` tab routing to `CompanyPage`.
4. Else: adds `ShellContent` tab per company routing to `EmployeePage_{ID}`.
5. Navigates to first tab using `///{targetRoute}` (rebuilds stack).
6. Falls back to `///InitialSetup` on DB failure.
### `OnManageCompaniesClicked(object sender, EventArgs e)`
- **Access:** `public async void`
- **Behavior:** Pushes `CompanyPage` onto navigation stack.
### `OnSettingsClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Pushes `SettingsPage` onto stack.
### `OnThemeClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Displays action sheet (`System`, `Light`, `Dark`). Updates `Application.Current.UserAppTheme` and persists to `Preferences.Default`.
### `OnExitClicked(object sender, EventArgs e)`
- **Access:** `private`
- **Behavior:** Calls `Application.Current?.Quit()`.
### `OnAboutClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Displays alert: `"Frymaster Badge App v1.0"`.
---
**Architecture Notes:**
- Uses `///` route prefix to force full navigation stack rebuild when switching initial tabs.
- Thread-safe UI updates via `MainThread.InvokeOnMainThreadAsync`.
- Graceful degradation to setup screen on database failure.

View File

@ -0,0 +1,73 @@
# EmployeeFormPage
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `ContentPage`
**Purpose:** Modal page for creating or editing employee records. Handles form binding, photo capture/cropping/zooming, badge generation, and SQL persistence.
## 🔑 Fields & Properties
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `_db` | `SqlService` | `private readonly` | Database access service. |
| `_existingEmployee` | `Dictionary<string, object>?` | `private readonly` | Holds employee data when in edit mode. |
| `OnSavedCallback` | `Action<Dictionary<string, object>>` | `public` | Callback triggered after successful save. |
| `_isEditMode` | `bool` | `private readonly` | Determines if page is adding or updating. |
| `_tempCapturePath` | `string` | `private` | Path to temporarily cached camera image. |
| `_editX`, `_editY` | `double` | `private` | Current pan offset for photo cropping. |
| `_photosBasePath` | `string` | `private readonly` | Directory for employee photos (from Preferences). |
| `IsSaved` | `bool` | `public` | Tracks if save operation completed successfully. |
| `SavedEmployee` | `Dictionary<string, object>?` | `public` | Stores minimal data of the saved record. |
## 🛠 Constructor
### `EmployeeFormPage(SqlService db, Dictionary<string, object>? employee = null)`
- **Access:** `public`
- **Parameters:** `db` (`SqlService`), `employee` (`Dictionary?`, optional)
- **Behavior:** Binds `ZoomSlider` to `Image.ScaleProperty`. Routes to `SetupEditMode` or `SetupAddMode` based on `employee` parameter.
## 📦 Methods
### `SetupEditMode(Dictionary<string, object> employee)`
- **Access:** `private`
- **Behavior:** Populates form fields, disables `Data1Entry`, parses dates/booleans safely, loads photo if exists, and applies saved crop/zoom state.
### `SetupAddMode()`
- **Access:** `private`
- **Behavior:** Resets form: title `"Add Employee"`, clears `Data1Entry`, sets date to today, checks `ActiveCheckBox`.
### `OnTakePhotoClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Captures photo via `MediaPicker`, saves to cache as `temp_form_capture.jpg`, updates preview, resets transforms.
### `OnPanUpdated(object sender, PanUpdatedEventArgs e)`
- **Access:** `private`
- **Behavior:** Updates `TranslationX/Y` in real-time during `Running`; commits offsets to `_editX/_editY` on `Completed`.
### `OnFetchBadgeClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Generates and assigns unique badge to `Data9Entry` via `GenerateUniqueBadgeNumberAsync()`.
### `OnSaveClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Validates `Data2Entry`, copies temp photo to `_photosBasePath` with unique name, builds `SqlParameter` array, executes `INSERT`/`UPDATE`, invokes callback, sets `IsSaved = true`, and pops modal.
### `GenerateUniqueBadgeNumberAsync()`
- **Access:** `private async Task<string>`
- **Returns:** `Task<string>` (Format: `*$F{6-digit}$A*`)
### `GenerateNextRecordNumber()`
- **Access:** `private`
- **Returns:** `string`
- **Behavior:** Queries `MAX(CAST(Data1 AS INT))`, returns next sequential number (starts at `100001`). *Currently unused in save flow.*
### `SafeToDouble(object? val, double defaultVal)`
- **Access:** `private`
- **Returns:** `double`
- **Behavior:** Safely parses object to `double`, returns `defaultVal` on failure/null.
### `OnCancelClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Closes modal without saving.
---
**⚠️ Developer Notes:**
- `OnSaveClicked` contains a nested `try/catch` that calls `Navigation.PopModalAsync()` twice on success. Remove the outer call to prevent `InvalidOperationException`.
- `GenerateNextRecordNumber()` is implemented but never invoked. Wire it to auto-fill `Data1Entry` or remove it.

View File

@ -0,0 +1,81 @@
# EmployeePage
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `ContentPage`
**Purpose:** Primary employee management interface. Handles list filtering, dynamic badge preview generation, photo editing, database CRUD operations, and badge printing.
## 🔑 Fields & Collections
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `_db` | `SqlService` | `private readonly` | Database access service. |
| `_printerService` | `PrinterService` | `private readonly` | Printing service. |
| `_selectedEmployee` | `Dictionary<string, object>?` | `private` | Currently selected employee record. |
| `_currentCompany` | `Dictionary<string, object>` | `private readonly` | Company context passed via DI. |
| `_allEmployees` | `ObservableCollection<Dictionary<string, object>>` | `private` | Master list of employees. |
| `_filteredEmployees` | `ObservableCollection<Dictionary<string, object>>` | `private` | Filtered view for UI binding. |
| `_photosBasePath`, `_logosBasePath`, `_imagesBasePath` | `string` | `private readonly` | Storage paths from Preferences. |
| `_currentBadgeType` | `string` | `private` | Tracks active badge style (`OFFICE`, `GUEST`, `PLANT`, etc.) |
| `_showActiveOnly` | `bool` | `private` | Active-only filter state. |
## 🛠 Constructor
### `EmployeePage(SqlService db, PrinterService printerService, Dictionary<string, object> company)`
- **Access:** `public`
- **Behavior:** Initializes UI, resolves XAML controls via `FindByName`, sets up event handlers (`ActiveFilter`, `Search`, `Guest/Badge Pickers`), ensures directories exist, loads company logo, fetches employees, and displays placeholder message.
## 📦 Methods
### UI & State Management
| Method | Access | Behavior |
|--------|--------|----------|
| `ShowNoSelectionMessage()` | `private` | Renders placeholder `VerticalStackLayout` in `PreviewFrame`. |
| `ResetPageToDefault()` | `private` | Clears selection, search text, filtered list, and resets preview. Prevents filter triggers during reset. |
| `OnAppearing()` | `protected override async void` | Resets UI, loads printers, and refreshes employee data. |
| `LoadCompanyLogo()` | `private` | Resolves logo path via `GetCompanyLogoPath()`. |
| `GetCompanyLogoPath()` | `private` | Combines `_logosBasePath` with DB filename. Validates existence, logs warnings. |
### Data Loading & Filtering
| Method | Access | Behavior |
|--------|--------|----------|
| `LoadEmployeesAsync()` | `private async Task` | Runs DB query on background thread. Marshals to main thread to populate `_allEmployees` and trigger filters. |
| `LoadEmployees()` | `private` | Synchronous variant (legacy). |
| `ApplyFilters()` | `private` | Filters `_allEmployees` by `_showActiveOnly` and search bar text. Updates `_filteredEmployees` on main thread. |
| `OnSearchTextChanged()` | `private` | Positions dropdown, shows list, triggers filters. |
| `PositionSearchResultsDropdown()` | `private` | Sets `TranslationY` and `ZIndex` for search results list. |
### Employee Selection & Navigation
| Method | Access | Behavior |
|--------|--------|----------|
| `OnEmployeeSelected()` | `private` | Updates search bar, resolves badge type (case-insensitive), toggles guest selector, renders preview. |
| `SelectAndShowEmployee(Dictionary)` | `private` | Helper to find record in collection, update UI, and scroll `ListView` to item. |
| `OnAddEmployeeClicked()` | `private async void` | Opens `EmployeeFormPage` modally. Sets callback to reload list and auto-select new record. |
| `OnEditEmployeeClicked()` | `private async void` | Opens form in edit mode. Refreshes data and preview if `IsSaved` is true. |
### Badge Rendering & Photo Editing
| Method | Access | Behavior |
|--------|--------|----------|
| `RenderBadgePreview(Dictionary)` | `private` | **Core method.** Dynamically builds front/back badge UI based on `_currentBadgeType`. Handles company logo, employee photo, barcode, medical indicators, and company details. |
| `AddStandardFront()` | `private` | UI builder for badge front (logo, photo container, name, barcode, medical icons). |
| `AddStandardBackDetails()` | `private` | UI builder for badge back (property text, address, ID, issue date, medical icons). |
| `OnTakePhotoClicked()` | `private async void` | Captures photo, saves to cache, resets editor transforms, shows overlay. |
| `OnPanUpdated()` | `private` | Updates photo `TranslationX/Y` during pan. Commits offsets on completion. |
| `OnApplyPhoto()` | `private async void` | Copies temp photo to `_photosBasePath`, updates DB (`picCode`, `cropScale`, `cropX`, `cropY`), refreshes preview. |
| `OnCancelPhoto()` | `private` | Hides photo editor overlay. |
| `InitialiseDefaultImages()` | `private async Task` | Deploys `Guest.png`/`WelBilt.png` from `Resources/Raw` to images directory if missing. |
### Utilities & Printing
| Method | Access | Behavior |
|--------|--------|----------|
| `SafeToDouble(object?, double)` | `private` | Safe parsing helper. Returns `defaultValue` on null/parse failure. |
| `OnBadgeTypeChanged()` | `private` | Updates `_currentBadgeType`, saves to DB, refreshes preview. |
| `OnGuestImageChanged()` | `private` | Updates `_currentGuestImage`, refreshes preview if type is `GUEST`. |
| `LoadAvailablePrinters()` | `private` | Populates `PrinterPicker` from `PrinterSettings.InstalledPrinters`. |
| `OnRefreshPrintersClicked()` | `private` | Reloads printer list. |
| `OnPrintClicked()` | `private async void` | Validates selection/printer. Captures front/back views via `CaptureAsync()`, converts to Base64, sends to `_printerService`. |
| `StreamToBase64(IScreenshotResult)` | `private async Task` | Converts screenshot stream to Base64 string for printing. |
---
**💡 Architectural Notes:**
- **Dynamic UI Generation:** Badge preview is built entirely in C# (`HorizontalStackLayout`, `Frame`, `Grid`). This allows precise control over print dimensions but increases memory overhead.
- **Threading:** DB queries run on `Task.Run` or background threads. All UI updates use `MainThread.BeginInvokeOnMainThread` or `InvokeOnMainThreadAsync`.
- **Null Safety:** Heavy use of `?.` and `FindByName` checks indicates defensive coding against missing XAML `x:Name` mappings.
- **Printing Flow:** Uses `CommunityToolkit.Maui` screenshot API to capture rendered UI, bypassing native printing drivers for pixel-perfect badge output.

View File

@ -0,0 +1,15 @@
# MainPage
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `ContentPage`
**Purpose:** Default content page stub. Currently acts as a placeholder entry point.
## 🛠 Constructor
### `MainPage()`
- **Access:** `public`
- **Behavior:** Calls `InitializeComponent()` to load XAML-defined UI. No additional logic or service injection is present.
---
**💡 Developer Notes:**
- Likely superseded by `AppShell` routing. If unused, consider removing or converting to a diagnostic/splash page.
- Ensure `MainPage.xaml` exists and contains valid root element if this class is referenced.

View File

@ -0,0 +1,36 @@
# MauiProgram
**Namespace:** `FrymasterBadgeApp`
**Type:** `static class`
**Purpose:** MAUI application bootstrap. Handles dependency injection setup, configuration loading, font registration, and service factory creation.
## 📦 Methods
### `CreateMauiApp()`
- **Access:** `public static`
- **Returns:** `MauiApp`
- **Behavior:**
1. Logs startup.
2. Initializes `MauiAppBuilder`, enables `CommunityToolkit`, and sets fonts (`OpenSans`, `BarcodeFont`).
3. Calls `LoadConfiguration(builder)`.
4. Registers services:
- `SqlService`: Factory-resolves connection string from `IConfiguration`. Falls back to hardcoded `Server=127.0.0.1...` if missing. Logs warning.
- `PrinterService`: Singleton.
- `AppShell`: Singleton.
- `EmployeePage`, `CompanyPage`, `SettingsPage`: Transient.
5. Enables debug logging in `DEBUG` builds.
6. Builds and returns `MauiApp`.
### `LoadConfiguration(MauiAppBuilder builder)`
- **Access:** `private static`
- **Behavior:** Configuration fallback chain:
1. **Primary:** Loads `appsettings.json` via `FileSystem.OpenAppPackageFileAsync()`. Parses and injects into builder.
2. **Fallback 1:** If file system fails, attempts `Assembly.GetManifestResourceStream("FrymasterBadgeApp.Resources.Raw.appsettings.json")`.
3. **Fallback 2:** If both fail, silently returns (triggers `SqlService` hardcoded fallback).
4. Logs success or warnings at each stage.
---
**💡 Developer Notes:**
- ⚠️ **Security Warning:** Hardcoded fallback connection string (`User Id=sa;Password=YourPassword123;`) should be removed or masked before production deployment.
- Configuration loading gracefully degrades, preventing app crashes on missing config files.
- Service lifetimes are appropriately scoped (Singleton for app-wide services, Transient for UI pages).

View File

@ -0,0 +1,71 @@
# SettingsPage
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `ContentPage`
**Purpose:** Configuration interface for managing file storage paths (Photos, Logos, Images) and global UI theme preferences.
## 🔑 Constants & Fields
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `PhotoPathKey` | `const string` | `private` | Preference key: `"PhotoBasePath"` |
| `LogoPathKey` | `const string` | `private` | Preference key: `"LogoBasePath"` |
| `ImagesPathKey` | `const string` | `private` | Preference key: `"ImagesBasePath"` |
| `ThemePrefKey` | `const string` | `private` | Preference key: `"AppTheme"` |
## 🛠 Constructor
### `SettingsPage()`
- **Access:** `public`
- **Behavior:**
1. Calls `InitializeComponent()`.
2. Loads current paths and theme via helper methods.
3. Wires `ThemePicker.SelectedIndexChanged` to `ApplyThemeSelection()`.
4. Overrides default back button behavior to navigate `Shell.Current.GoToAsync("..")`.
## 📦 Methods
### `LoadCurrentSettings()`
- **Access:** `private`
- **Behavior:** Reads photo, logo, and image paths from `Preferences.Default`. Falls back to `C:\FrymasterData\...` and populates corresponding UI entries.
### `LoadCurrentThemePreference()`
- **Access:** `private`
- **Behavior:** Reads `"AppTheme"` preference. Maps to `Light`/`Dark`/`System`, updates `ThemePicker.SelectedIndex` and `Application.Current.UserAppTheme`.
### `ApplyThemeSelection()`
- **Access:** `private`
- **Behavior:** Reads `ThemePicker.SelectedIndex`. Updates `UserAppTheme` and persists selection to `Preferences.Default`. Handles null picker defensively.
### `OnPickPhotoFolderClicked`, `OnPickLogoFolderClicked`, `OnPickImagesFolderClicked`
- **Access:** `private async void`
- **Behavior:** Wrappers that invoke `PickFolderAsync` with specific update callbacks and dialog titles.
### `PickFolderAsync(Action<FolderPickerResult> onSuccess, string title)`
- **Access:** `private async Task`
- **Parameters:**
- `onSuccess` (`Action<FolderPickerResult>`) Callback on successful selection.
- `title` (`string`) Picker dialog title.
- **Behavior:** Invokes `FolderPicker.Default.PickAsync()`. On success, executes callback and calls `VerifyDirectory()`. Catches and displays alerts on failure.
### `OnSaveClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:**
1. Trims and validates all three paths. Alerts if any are empty.
2. Saves paths to `Preferences.Default`.
3. Updates `StatusLabel` with success/error message and color.
4. Shows `Toast` (non-critical, wrapped in try/catch).
5. **Intentionally omits auto-navigation** to prevent known Shell navigation crashes.
6. Logs extensively via `AppLogger`.
### `SafeCreateDirectory(string path)`
- **Access:** `private static`
- **Behavior:** *(Commented out in current code but present)* Creates directory if missing. Logs failures but suppresses crashes.
### `VerifyDirectory(string path)`
- **Access:** `private static`
- **Behavior:** Checks `Directory.Exists(path)`. Creates it if missing. Used immediately after folder selection.
---
**💡 Developer Notes:**
- Uses `CommunityToolkit.Maui.Alerts` and `Storage` for modern picker/toast functionality.
- Hardcoded fallback paths (`C:\FrymasterData\...`) should be platform-aware for iOS/macOS/Android.
- Explicitly avoids `Shell.Current.GoToAsync("..")` after save to maintain navigation stability.

View File

@ -0,0 +1,69 @@
# CompanyPage
**Namespace:** `FrymasterBadgeApp`
**Base Class:** `ContentPage`
**Purpose:** CRUD interface for managing company records and uploading/previewing company logos.
## 🔑 Fields
| Name | Type | Access | Description |
|------|------|--------|-------------|
| `_db` | `SqlService` | `private readonly` | Database service. |
| `_selectedCompany` | `Dictionary<string, object>?` | `private` | Currently selected company from the list. |
| `_tempLogoPath` | `string` | `private` | Path to currently selected/cached logo image. |
| `LogoPathKey` | `const string` | `private` | Preference key for logo directory (`"LogoBasePath"`). |
## 🛠 Constructor
### `CompanyPage(SqlService db)`
- **Access:** `public`
- **Behavior:** Configures custom back button behavior, calls `LoadCompanies()`.
## 📦 Methods
### `GetLogoDirectory()`
- **Access:** `private`
- **Returns:** `string`
- **Behavior:** Reads logo directory from Preferences. Defaults to `C:\FrymasterData\logos`.
### `LoadCompanies()`
- **Access:** `private`
- **Behavior:** Executes `SELECT * FROM Companies ORDER BY Name ASC`, binds to `CompanyList`. Catches and alerts on DB failure.
### `OnCompanySelectionChanged(object sender, SelectionChangedEventArgs e)`
- **Access:** `private`
- **Behavior:** Updates form fields and loads logo preview on main thread. Clears fields if selection is null.
### `GetValue(Dictionary<string, object> dict, string key)`
- **Access:** `private`
- **Returns:** `string`
- **Behavior:** Case-insensitive dictionary lookup. Returns trimmed value or empty string if missing.
### `OnSelectLogoClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Opens file picker filtering for images. Stores `FullPath` in `_tempLogoPath` and updates preview.
### `OnSaveClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:**
1. Validates company name.
2. Ensures logo directory exists (creates if missing).
3. If logo selected: copies to directory with timestamped name on collision. Stores only filename.
4. Executes `INSERT` or `UPDATE` via `_db.Execute`.
5. Refreshes list and clears form.
### `OnAddNewClicked(object sender, EventArgs e)`
- **Access:** `private`
- **Behavior:** Deselects list item and clears form fields.
### `ClearFields()`
- **Access:** `private`
- **Behavior:** Resets `_selectedCompany`, `_tempLogoPath`, all text fields, logo preview, and hides delete button.
### `OnDeleteClicked(object sender, EventArgs e)`
- **Access:** `private async void`
- **Behavior:** Shows confirmation dialog, executes `DELETE FROM Companies WHERE ID=@id`, refreshes list, clears fields. Catches and alerts on error.
---
**Data Flow Notes:**
- Logo paths are stored as filenames only in the database to keep DB portable.
- File operations use `File.Copy` with overwrite protection (`timestamp` suffix).
- All UI updates after async operations are marshaled via `MainThread.BeginInvokeOnMainThread`.

174
EmployeeFormPage.xaml Normal file
View File

@ -0,0 +1,174 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="FrymasterBadgeApp.EmployeeFormPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
Title="Employee Editor">
<ScrollView>
<VerticalStackLayout Padding="0" Spacing="0">
<Grid
Padding="20,30"
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Tertiary}}"
ColumnDefinitions="*, Auto">
<VerticalStackLayout Grid.Column="0" Spacing="5">
<Label
x:Name="TitleLabel"
FontAttributes="Bold"
FontSize="28"
Text="Employee Details" />
<Label
FontSize="14"
Text="Manage badge information and photo"
TextColor="{StaticResource Gray500}" />
</VerticalStackLayout>
<Button
Grid.Column="1"
BackgroundColor="Transparent"
Clicked="OnCancelClicked"
FontSize="20"
Text="X"
TextColor="{StaticResource Gray500}" />
</Grid>
<VerticalStackLayout Padding="30" Spacing="25">
<Grid ColumnDefinitions="*, *" ColumnSpacing="40">
<VerticalStackLayout Grid.Column="0" Spacing="18">
<VerticalStackLayout Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="Record Number"
TextColor="{StaticResource Primary}" />
<Entry x:Name="Data1Entry" Placeholder="ID Number" />
</VerticalStackLayout>
<VerticalStackLayout Spacing="5">
<Label
FontAttributes="Bold"
FontSize="12"
Text="Full Name"
TextColor="{StaticResource Primary}" />
<Entry x:Name="Data2Entry" Placeholder="Required" />
</VerticalStackLayout>
<Grid ColumnDefinitions="*, *" ColumnSpacing="15">
<VerticalStackLayout Grid.Column="0" Spacing="5">
<Label FontSize="12" Text="Preferred Name" />
<Entry x:Name="Data3Entry" />
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1" Spacing="5">
<Label FontSize="12" Text="Last Name" />
<Entry x:Name="LastNameEntry" />
</VerticalStackLayout>
</Grid>
<VerticalStackLayout Spacing="5">
<Label FontSize="12" Text="Job Title" />
<Entry x:Name="Data4Entry" />
</VerticalStackLayout>
<Grid ColumnDefinitions="*, *" ColumnSpacing="15">
<VerticalStackLayout Grid.Column="0" Spacing="5">
<Label FontSize="12" Text="Issue Date" />
<DatePicker x:Name="Data5DatePicker" />
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1" Spacing="5">
<Label FontSize="12" Text="Badge Barcode" />
<Grid ColumnDefinitions="*, Auto">
<Entry
x:Name="Data9Entry"
FontSize="11"
IsReadOnly="True" />
<Button
x:Name="BadgeFetchButton"
Grid.Column="1"
Padding="10,0"
BackgroundColor="{StaticResource Secondary}"
Clicked="OnFetchBadgeClicked"
Text="🔄" />
</Grid>
</VerticalStackLayout>
</Grid>
<Frame Style="{StaticResource CardFrame}">
<HorizontalStackLayout HorizontalOptions="Center" Spacing="20">
<HorizontalStackLayout Spacing="5">
<CheckBox x:Name="MedicalCheckBox" Color="DodgerBlue" />
<Label Text="Medical" VerticalOptions="Center" />
</HorizontalStackLayout>
<HorizontalStackLayout Spacing="5">
<CheckBox x:Name="EmsCheckBox" Color="Red" />
<Label Text="EMS" VerticalOptions="Center" />
</HorizontalStackLayout>
<HorizontalStackLayout Spacing="5">
<CheckBox x:Name="ActiveCheckBox" Color="{StaticResource Primary}" />
<Label Text="Active" VerticalOptions="Center" />
</HorizontalStackLayout>
</HorizontalStackLayout>
</Frame>
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1" HorizontalOptions="Center" Spacing="20">
<Frame
Padding="0"
BackgroundColor="Black"
BorderColor="{StaticResource Primary}"
CornerRadius="140"
HeightRequest="280"
IsClippedToBounds="True"
WidthRequest="280">
<Image x:Name="EditorPhotoPreview" Aspect="AspectFill">
<Image.GestureRecognizers>
<PanGestureRecognizer PanUpdated="OnPanUpdated" />
</Image.GestureRecognizers>
</Image>
</Frame>
<VerticalStackLayout Spacing="10" WidthRequest="280">
<Label
FontSize="12"
HorizontalOptions="Center"
Text="Zoom Control" />
<Slider
x:Name="ZoomSlider"
Maximum="4"
Minimum="1"
MinimumTrackColor="{StaticResource Primary}"
Value="1" />
<Button
BackgroundColor="{StaticResource Primary}"
Clicked="OnTakePhotoClicked"
CornerRadius="20"
HeightRequest="45"
Text="Take New Photo"
TextColor="White" />
</VerticalStackLayout>
</VerticalStackLayout>
</Grid>
<BoxView Margin="0,10" HeightRequest="1" Color="{StaticResource Gray500}" />
<HorizontalStackLayout HorizontalOptions="End" Spacing="15">
<Button
BackgroundColor="Transparent"
Clicked="OnCancelClicked"
Text="Cancel"
TextColor="{DynamicResource AppTextColor}"
WidthRequest="100" />
<Button
x:Name="SaveButton"
BackgroundColor="{StaticResource Primary}"
Clicked="OnSaveClicked"
CornerRadius="5"
FontAttributes="Bold"
Text="Save Changes"
TextColor="White"
WidthRequest="200" />
</HorizontalStackLayout>
</VerticalStackLayout>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

219
EmployeeFormPage.xaml.cs Normal file
View File

@ -0,0 +1,219 @@
using System.Text;
using FrymasterBadgeApp.Services;
using Microsoft.Data.SqlClient;
namespace FrymasterBadgeApp;
public partial class EmployeeFormPage : ContentPage
{
private readonly SqlService _db;
private readonly Dictionary<string, object>? _existingEmployee;
public Action<Dictionary<string, object>> OnSavedCallback { get; set; }
private readonly bool _isEditMode;
private string _tempCapturePath = "";
private double _editX = 0;
private double _editY = 0;
private readonly string _photosBasePath;
public bool IsSaved { get; private set; } = false;
public Dictionary<string, object>? SavedEmployee { get; private set; }
public EmployeeFormPage(SqlService db, Dictionary<string, object>? employee = null)
{
InitializeComponent();
_db = db;
_existingEmployee = employee;
_isEditMode = employee != null;
_photosBasePath = Preferences.Default.Get("PhotoBasePath", @"C:\FrymasterData\photos");
EditorPhotoPreview.SetBinding(
Image.ScaleProperty,
new Binding("Value", source: ZoomSlider)
);
if (_isEditMode)
SetupEditMode(employee!);
else
SetupAddMode();
}
private void SetupEditMode(Dictionary<string, object> employee)
{
TitleLabel.Text = "Edit Employee";
SaveButton.Text = "Update";
BadgeFetchButton.Text = "Update Badge";
Data1Entry.Text = employee.GetValueOrDefault("Data1")?.ToString() ?? "";
Data1Entry.IsEnabled = false;
Data2Entry.Text = employee.GetValueOrDefault("Data2")?.ToString() ?? "";
Data3Entry.Text = employee.GetValueOrDefault("Data3")?.ToString() ?? "";
LastNameEntry.Text = employee.GetValueOrDefault("LastName")?.ToString() ?? "";
Data4Entry.Text = employee.GetValueOrDefault("Data4")?.ToString() ?? "";
Data9Entry.Text = employee.GetValueOrDefault("Data9")?.ToString() ?? "";
if (
DateTime.TryParse(employee.GetValueOrDefault("Data5")?.ToString(), out DateTime dateVal)
)
Data5DatePicker.Date = dateVal;
MedicalCheckBox.IsChecked =
(employee.GetValueOrDefault("Data7")?.ToString() ?? "False") == "True";
EmsCheckBox.IsChecked = (employee.GetValueOrDefault("Data8")?.ToString() ?? "0") == "1";
ActiveCheckBox.IsChecked =
(employee.GetValueOrDefault("Active")?.ToString()?.ToUpper() ?? "NO") == "YES";
string picCode = employee.GetValueOrDefault("picCode")?.ToString() ?? "";
string photoPath = Path.Combine(_photosBasePath, $"{picCode}.jpg");
if (File.Exists(photoPath))
{
EditorPhotoPreview.Source = ImageSource.FromFile(photoPath);
_editX = SafeToDouble(employee.GetValueOrDefault("cropX"), 0.0);
_editY = SafeToDouble(employee.GetValueOrDefault("cropY"), 0.0);
EditorPhotoPreview.TranslationX = _editX;
EditorPhotoPreview.TranslationY = _editY;
ZoomSlider.Value = SafeToDouble(employee.GetValueOrDefault("cropScale"), 1.0);
}
}
private void SetupAddMode()
{
TitleLabel.Text = "Add Employee";
Data1Entry.Text = "";
Data5DatePicker.Date = DateTime.Today;
ActiveCheckBox.IsChecked = true;
}
private async void OnTakePhotoClicked(object sender, EventArgs e)
{
try
{
var photo = await MediaPicker.Default.CapturePhotoAsync();
if (photo == null)
return;
_tempCapturePath = Path.Combine(FileSystem.CacheDirectory, "temp_form_capture.jpg");
using (var source = await photo.OpenReadAsync())
using (var destination = File.OpenWrite(_tempCapturePath))
await source.CopyToAsync(destination);
EditorPhotoPreview.Source = ImageSource.FromFile(_tempCapturePath);
_editX = _editY = 0;
EditorPhotoPreview.TranslationX = 0;
EditorPhotoPreview.TranslationY = 0;
ZoomSlider.Value = 1;
}
catch (Exception ex)
{
await DisplayAlert("Error", ex.Message, "OK");
}
}
private void OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
if (e.StatusType == GestureStatus.Running)
{
EditorPhotoPreview.TranslationX = _editX + e.TotalX;
EditorPhotoPreview.TranslationY = _editY + e.TotalY;
}
else if (e.StatusType == GestureStatus.Completed)
{
_editX = EditorPhotoPreview.TranslationX;
_editY = EditorPhotoPreview.TranslationY;
}
}
private async void OnFetchBadgeClicked(object sender, EventArgs e)
{
Data9Entry.Text = await GenerateUniqueBadgeNumberAsync();
}
private async void OnSaveClicked(object sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(Data2Entry.Text))
return;
try
{
string recordNum = Data1Entry.Text.Trim();
string picCode = _existingEmployee?.GetValueOrDefault("picCode")?.ToString() ?? "";
if (!string.IsNullOrEmpty(_tempCapturePath))
{
picCode = $"{recordNum}_{DateTime.Now.Ticks}";
File.Copy(_tempCapturePath, Path.Combine(_photosBasePath, $"{picCode}.jpg"), true);
}
var parameters = new List<SqlParameter>
{
new SqlParameter("@Data1", recordNum),
new SqlParameter("@Data2", Data2Entry.Text),
new SqlParameter("@Data3", Data3Entry.Text ?? ""),
new SqlParameter("@LastName", LastNameEntry.Text ?? ""),
new SqlParameter("@Data4", Data4Entry.Text ?? ""),
new SqlParameter("@Data5", Data5DatePicker.Date),
new SqlParameter("@Data9", Data9Entry.Text ?? ""),
new SqlParameter("@Data7", MedicalCheckBox.IsChecked ? "True" : "False"),
new SqlParameter("@Data8", EmsCheckBox.IsChecked ? "1" : "0"),
new SqlParameter("@Active", ActiveCheckBox.IsChecked ? "YES" : "NO"),
new SqlParameter("@picCode", picCode),
new SqlParameter("@cropX", EditorPhotoPreview.TranslationX),
new SqlParameter("@cropY", EditorPhotoPreview.TranslationY),
new SqlParameter("@cropScale", ZoomSlider.Value),
};
string sql = _isEditMode
? "UPDATE dbo.tblData SET Data1=@Data1, Data2=@Data2, Data3=@Data3, LastName=@LastName, Data4=@Data4, Data5=@Data5, Data9=@Data9, Data7=@Data7, Data8=@Data8, Active=@Active, picCode=@picCode, cropX=@cropX, cropY=@cropY, cropScale=@cropScale WHERE Data1=@Data1"
: "INSERT INTO dbo.tblData (Data1, Data2, Data3, LastName, Data4, Data5, Data9, Data7, Data8, Active, picCode, cropX, cropY, cropScale, BadgeType, ToPrint, CdsPrinted) VALUES (@Data1, @Data2, @Data3, @LastName, @Data4, @Data5, @Data9, @Data7, @Data8, @Active, @picCode, @cropX, @cropY, @cropScale, 'Office', 0, 0)";
_db.Execute(sql, parameters.ToArray());
try
{
// After successful DB execution:
var savedData = new Dictionary<string, object>
{
{ "Data1", recordNum },
{ "Data2", Data2Entry.Text },
};
// Trigger the callback before closing
OnSavedCallback?.Invoke(savedData);
await Navigation.PopModalAsync();
}
catch (Exception ex)
{
await DisplayAlert("Error", ex.Message, "OK");
}
IsSaved = true;
await Navigation.PopModalAsync();
}
catch (Exception ex)
{
await DisplayAlert("Save Error", ex.Message, "OK");
}
}
private async Task<string> GenerateUniqueBadgeNumberAsync()
{
return $"*$F{Random.Shared.Next(100000, 999999)}$A*";
}
private string GenerateNextRecordNumber()
{
var result = _db.Query(
"SELECT MAX(CAST(Data1 AS INT)) FROM dbo.tblData WHERE ISNUMERIC(Data1) = 1",
null
)
.FirstOrDefault();
return (
result != null ? (int.Parse(result.Values.First().ToString()) + 1) : 100001
).ToString();
}
private double SafeToDouble(object? val, double defaultVal) =>
val != null && double.TryParse(val.ToString(), out double res) ? res : defaultVal;
private async void OnCancelClicked(object sender, EventArgs e) =>
await Navigation.PopModalAsync();
}

View File

@ -1,241 +1,239 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="FrymasterBadgeApp.EmployeePage"
BackgroundColor="#0f172a"
>
<Grid RowDefinitions="80,*" ColumnDefinitions="400,*">
<!-- Header -->
<Border Grid.ColumnSpan="2" BackgroundColor="#1e293b" StrokeThickness="0" Padding="20,0">
<Grid ColumnDefinitions="Auto,*">
<Label
Text="FRYMASTER BADGE SYSTEM"
FontSize="20"
FontAttributes="Bold"
TextColor="White"
VerticalOptions="Center"
/>
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Grid RowDefinitions="Auto, *">
<Frame
Grid.Row="0"
Padding="10"
BackgroundColor="{AppThemeBinding Light={StaticResource BgLight},
Dark={StaticResource Tertiary}}"
BorderColor="Transparent"
CornerRadius="0"
HasShadow="False">
<VerticalStackLayout HorizontalOptions="Start" Spacing="15">
<FlexLayout
AlignItems="Center"
Direction="Row"
JustifyContent="Start"
Wrap="Wrap">
<VerticalStackLayout Margin="5" WidthRequest="300">
<SearchBar
Grid.Column="1"
x:Name="EmployeeSearchBar"
Placeholder="Search employees..."
TextColor="White"
BackgroundColor="#334155"
PlaceholderColor="#94a3b8"
Margin="10,0"
VerticalOptions="Center"
TextChanged="OnSearchTextChanged"
/>
</Grid>
</Border>
<!-- Left: Employee List -->
<Border Grid.Row="1" BackgroundColor="#1e293b" StrokeThickness="0">
<CollectionView
x:Name="EmployeeList"
SelectionMode="Single"
SelectionChanged="OnSelectionChanged"
BackgroundColor="Transparent"
Margin="10"
>
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="8" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate>
<Border
BackgroundColor="#334155"
Padding="15"
StrokeShape="RoundRectangle 8"
Shadow="Shadow 0 4 8 0.2 Black"
>
<Label
Text="{Binding [Data2]}"
TextColor="White"
FontSize="16"
FontAttributes="Bold"
/>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Border>
HeightRequest="50"
Placeholder="Search Employee..."
PlaceholderColor="{StaticResource Gray500}"
TextChanged="OnSearchTextChanged"
TextColor="{StaticResource Gray500}" />
</VerticalStackLayout>
<!-- Right: Preview & Controls -->
<ScrollView Grid.Row="1" Grid.Column="1" Padding="40,20">
<VerticalStackLayout Spacing="25" HorizontalOptions="Center">
<!-- Company Logo (used in badge preview) -->
<Image
x:Name="PreviewLogoFront"
HeightRequest="60"
Aspect="AspectFit"
HorizontalOptions="Center"
/>
<!-- Badge Type & Guest Selector -->
<Border
BackgroundColor="#1e293b"
Padding="20"
StrokeShape="RoundRectangle 12"
Shadow="Shadow 0 4 10 0.3 Black"
>
<VerticalStackLayout Spacing="15">
<HorizontalStackLayout Spacing="15" HorizontalOptions="Center">
<HorizontalStackLayout>
<CheckBox
x:Name="ActiveFilterCheckBox"
Margin="0,0,10,0"
CheckedChanged="OnActiveFilterChanged"
IsChecked="True"
VerticalOptions="Center" />
<Label
Text="Badge Type"
TextColor="White"
FontAttributes="Bold"
FontSize="16"
VerticalOptions="Center"
/>
<Picker
x:Name="BadgeTypePicker"
Title="Select type"
WidthRequest="250"
TextColor="White"
BackgroundColor="#334155"
SelectedIndexChanged="OnBadgeTypeChanged"
>
<Picker.Items>
<x:String>Office</x:String>
<x:String>Plant</x:String>
<x:String>Maintenance</x:String>
<x:String>Guest</x:String>
<x:String>Visitor</x:String>
<x:String>Vendor</x:String>
</Picker.Items>
</Picker>
Margin="0,0,40,0"
Text="Active Only"
VerticalOptions="Center" />
</HorizontalStackLayout>
<HorizontalStackLayout
x:Name="GuestImageSelector"
Margin="5"
IsVisible="False"
Spacing="15"
HorizontalOptions="Center"
>
<Label
Text="Guest Image"
TextColor="White"
FontAttributes="Bold"
FontSize="16"
VerticalOptions="Center"
/>
Spacing="5">
<Label Text="Guest Img:" VerticalOptions="Center" />
<Picker
x:Name="GuestImagePicker"
Title="Select image"
WidthRequest="250"
TextColor="White"
BackgroundColor="#334155"
SelectedIndexChanged="OnGuestImageChanged"
>
TextColor="{DynamicResource AppTextColor}"
WidthRequest="130">
<Picker.Items>
<x:String>Guest1.png</x:String>
<x:String>Guest2.png</x:String>
<x:String>Guest3.png</x:String>
<x:String>Guest.jpg</x:String>
<x:String>WelBilt.jpg</x:String>
</Picker.Items>
</Picker>
</HorizontalStackLayout>
</VerticalStackLayout>
</Border>
<!-- Badge Preview (Front + Back side-by-side) -->
<Border
x:Name="PreviewFrame"
BackgroundColor="Transparent"
Padding="30"
StrokeShape="RoundRectangle 20"
Shadow="Shadow 0 8 20 0.4 Black"
HorizontalOptions="Center"
>
<!-- Content dynamically set in code-behind -->
</Border>
<!-- Action Buttons -->
<HorizontalStackLayout Spacing="20" HorizontalOptions="Center">
<Button
Text="📷 Edit / Capture Photo"
Clicked="OnTakePhotoClicked"
BackgroundColor="#3b82f6"
TextColor="White"
FontAttributes="Bold"
CornerRadius="12"
HeightRequest="55"
WidthRequest="220"
/>
<Button
Text="🖨️ Print Badge"
Clicked="OnPrintClicked"
BackgroundColor="#6366f1"
TextColor="White"
FontAttributes="Bold"
CornerRadius="12"
HeightRequest="55"
WidthRequest="220"
/>
<HorizontalStackLayout Margin="5" Spacing="5">
<Label Text="Badge Type:" VerticalOptions="Center" />
<Picker
x:Name="BadgeTypePicker"
SelectedIndexChanged="OnBadgeTypeChanged"
TextColor="{StaticResource Gray500}"
WidthRequest="150">
<Picker.Items>
<x:String>OFFICE</x:String>
<x:String>PLANT</x:String>
<x:String>MAINTENANCE</x:String>
<x:String>GUEST</x:String>
<x:String>VENDOR</x:String>
</Picker.Items>
</Picker>
</HorizontalStackLayout>
<HorizontalStackLayout Margin="5" Spacing="5">
<Label Text="Printer:" VerticalOptions="Center" />
<Picker
x:Name="PrinterPicker"
TextColor="{StaticResource Gray500}"
WidthRequest="180" />
<Button
BackgroundColor="Transparent"
Clicked="OnRefreshPrintersClicked"
Text="🔄"
TextColor="{StaticResource Gray500}" />
</HorizontalStackLayout>
</FlexLayout>
<FlexLayout
AlignItems="Center"
Direction="Row"
JustifyContent="Center"
Wrap="Wrap">
<Button
Margin="5"
Padding="20,0"
BackgroundColor="{StaticResource Success}"
Clicked="OnAddEmployeeClicked"
FontAttributes="Bold"
HeightRequest="45"
Text="+ Add Employee"
TextColor="White" />
<Button
x:Name="EditSelectedButton"
Margin="5"
BackgroundColor="{StaticResource Info}"
Clicked="OnEditEmployeeClicked"
FontAttributes="Bold"
HeightRequest="45"
Text="✎ Edit Selected"
TextColor="White"
WidthRequest="300" />
<Button
Padding="20,0"
BackgroundColor="{StaticResource Primary}"
Clicked="OnPrintClicked"
FontAttributes="Bold"
HeightRequest="45"
Text="PRINT BADGE"
TextColor="White" />
</FlexLayout>
</VerticalStackLayout>
</Frame>
<ScrollView Grid.Row="1" Padding="20">
<VerticalStackLayout HorizontalOptions="Center" Spacing="20">
<Frame
x:Name="PreviewFrame"
Padding="30"
BackgroundColor="Transparent"
BorderColor="{AppThemeBinding Light={StaticResource BorderLight},
Dark={StaticResource BorderDark}}"
CornerRadius="20"
HasShadow="True"
HorizontalOptions="Center" />
<Button
BackgroundColor="{StaticResource Info}"
Clicked="OnTakePhotoClicked"
CornerRadius="10"
FontAttributes="Bold"
HeightRequest="50"
Text="📷 EDIT PHOTO"
TextColor="White"
WidthRequest="300" />
</VerticalStackLayout>
</ScrollView>
</Grid>
<!-- Photo Editor Overlay -->
<Grid x:Name="PhotoEditorOverlay" IsVisible="False" BackgroundColor="#CC000000">
<Border
BackgroundColor="#1e293b"
Padding="30"
StrokeShape="RoundRectangle 20"
Shadow="Shadow 0 8 20 0.4 Black"
HorizontalOptions="Center"
VerticalOptions="Center"
WidthRequest="500"
>
<VerticalStackLayout Spacing="25">
<Label
Text="Adjust Photo"
FontSize="20"
FontAttributes="Bold"
TextColor="White"
HorizontalTextAlignment="Center"
/>
<Border
BackgroundColor="Black"
StrokeShape="RoundRectangle 12"
Padding="0"
IsClippedToBounds="True"
HeightRequest="300"
<ListView
x:Name="SearchResultsList"
Grid.RowSpan="2"
Margin="0,0,20,0"
BackgroundColor="{AppThemeBinding Light={StaticResource White},
Dark={StaticResource BgDark}}"
HeightRequest="250"
HorizontalOptions="Start"
IsVisible="False"
ItemSelected="OnEmployeeSelected"
VerticalOptions="Start"
WidthRequest="300"
>
<Image x:Name="EditorPhotoPreview" Aspect="AspectFill">
<Image.GestureRecognizers>
<PanGestureRecognizer PanUpdated="OnPanUpdated" />
</Image.GestureRecognizers>
</Image>
</Border>
<VerticalStackLayout Spacing="8">
<Label Text="Zoom" TextColor="#cbd5e1" HorizontalTextAlignment="Center" />
<Slider x:Name="ZoomSlider" Minimum="1" Maximum="4" Value="1" />
ZIndex="100">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<VerticalStackLayout Padding="10">
<Label
FontAttributes="Bold"
Text="{Binding [Data2]}"
TextColor="{StaticResource Gray500}" />
<Label
FontSize="12"
Text="{Binding [Data1]}"
TextColor="{StaticResource Gray500}" />
</VerticalStackLayout>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Grid ColumnDefinitions="*,*" ColumnSpacing="20">
<Grid
x:Name="PhotoEditorOverlay"
Grid.RowSpan="2"
BackgroundColor="#80000000"
IsVisible="False">
<Border
Padding="25"
HorizontalOptions="Center"
Style="{StaticResource CardStyle}"
VerticalOptions="Center"
WidthRequest="450">
<VerticalStackLayout Spacing="20">
<Label
FontAttributes="Bold"
FontSize="18"
HorizontalOptions="Center"
Text="PHOTO ADJUSTMENT" />
<Frame
Padding="0"
BackgroundColor="Black"
CornerRadius="10"
HeightRequest="300"
IsClippedToBounds="True"
WidthRequest="300">
<Image x:Name="EditorPhotoPreview" Aspect="AspectFill" />
</Frame>
<VerticalStackLayout Spacing="5">
<Label HorizontalOptions="Center" Text="Zoom Level" />
<Slider
x:Name="ZoomSlider"
Maximum="4"
Minimum="1"
Value="1" />
</VerticalStackLayout>
<Grid ColumnDefinitions="*, *" ColumnSpacing="15">
<Button
Text="Cancel"
BackgroundColor="{StaticResource Secondary}"
Clicked="OnCancelPhoto"
BackgroundColor="#ef4444"
TextColor="White"
CornerRadius="10"
/>
Text="CANCEL"
TextColor="White" />
<Button
Grid.Column="1"
Text="Save Crop"
BackgroundColor="{StaticResource Success}"
Clicked="OnApplyPhoto"
BackgroundColor="#22c55e"
TextColor="White"
CornerRadius="10"
/>
Text="SAVE CROP"
TextColor="White" />
</Grid>
</VerticalStackLayout>
</Border>
</Grid>
</Grid>
</ContentPage>

File diff suppressed because it is too large Load Diff

View File

@ -1,85 +1,75 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-windows10.0.19041.0</TargetFrameworks>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<OutputType>WinExe</OutputType>
<RootNamespace>FrymasterBadgeApp</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCssItems>false</EnableDefaultCssItems>
<Nullable>enable</Nullable>
<ApplicationTitle>FrymasterBadgeApp</ApplicationTitle>
<ApplicationId>com.c4b3r.frymasterbadgeapp</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<SupportedOSPlatformVersion
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'"
>10.0.17763.0</SupportedOSPlatformVersion
>
<TargetPlatformMinVersion
Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'"
>10.0.17763.0</TargetPlatformMinVersion
>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Platforms>x64</Platforms>
<NoWarn>$(NoWarn);MSB3277;NU1605</NoWarn>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<GenerateDefaultValueAttribute>false</GenerateDefaultValueAttribute>
<GenerateDocumentationFile>False</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Optimize>False</Optimize>
</PropertyGroup>
<ItemGroup>
<MauiIcon
Include="Resources\AppIcon\appicon.svg"
ForegroundFile="Resources\AppIcon\appiconfg.svg"
Color="#512BD4"
/>
<MauiSplashScreen Include="Resources\Splash\splash.png" Color="#512BD4" BaseSize="128,128" />
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.svg" BaseSize="168,208" />
<MauiFont Include="Resources\Fonts\*" />
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
<EmbeddedResource Include="Resources\Raw\appsettings.json" />
</ItemGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0-windows10.0.19041.0'">
<MauiEnableXamlC>false</MauiEnableXamlC>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.1" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Lextm.SharpSnmpLib" Version="12.5.6" />
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="3.116.1" />
<PackageReference Include="Neodynamic.SDK.BarcodeCore" Version="6.0.24.513" />
</ItemGroup>
<ItemGroup>
<Reference Include="SdkApi.Card.Core">
<HintPath>Libs\zebra\Sdk\SdkApi.Card.Core.dll</HintPath>
</Reference>
<Reference Include="SdkApi.Card.Desktop">
<HintPath>Libs\zebra\Sdk\SdkApi.Card.Desktop.dll</HintPath>
</Reference>
<!-- Remove any old/fixed version pins if present -->
<!-- Update or add these with exact or minimum version -->
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.120" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.120" />
<!-- if you use it -->
<PackageReference Include="Microsoft.Maui.Core" Version="9.0.120" />
<!-- optional but good to align -->
<!-- Keep or add the toolkit -->
<PackageReference Include="CommunityToolkit.Maui" Version="12.3.0" />
<!-- Fix WindowsAppSDK if needed (usually follows MAUI) -->
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.7.250909003" />
</ItemGroup>
<ItemGroup>
<MauiAsset Include="Resources\Raw\appsettings.json" LogicalName="appsettings.json" />
<MauiFont Include="Resources\Fonts\*" />
</ItemGroup>
<ItemGroup>
<!-- <PackageReference Include="SdkApi.Core" Version="2.0.0" />
<PackageReference Include="SdkApi.Card.Core" Version="2.0.0" />
<PackageReference Include="SdkApi.Desktop" Version="2.0.0" />-->
<Reference Include="SdkApi.Core">
<HintPath>Libs\zebra\Sdk\SdkApi.Core.dll</HintPath>
</Reference>
<Reference Include="SdkApi.Card.Core">
<HintPath>Libs\zebra\Sdk\SdkApi.Card.Core.dll</HintPath>
</Reference>
<Reference Include="SdkApi.Desktop">
<HintPath>Libs\zebra\Sdk\SdkApi.Desktop.dll</HintPath>
</Reference>
<Reference Include="SdkApi.Desktop.Usb">
<HintPath>Libs\zebra\Sdk\SdkApi.Desktop.Usb.dll</HintPath>
</Reference>
<!--
<Content Include="Libs\zebra\Sdk\*.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>false</Visible>
</Content>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<Link>%(Filename)%(Extension)</Link>
</Content> -->
</ItemGroup>
<ItemGroup>
<MauiXaml Update="Resources\Themes\DarkTheme.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
</ItemGroup>
<!-- XAML files are compiled by the MAUI SDK implicitly; no explicit MauiXaml item includes to avoid duplicate resources -->
</Project>

View File

@ -5,7 +5,7 @@
x:Class="FrymasterBadgeApp.MainPage"
>
<VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Spacing="20">
<Label Text="Frymaster Badge System" FontSize="24" FontAttributes="Bold" TextColor="#1F2937" />
<Label Text="Select a tab below to begin." TextColor="Gray" />
<Label Text="Frymaster Badge System" FontSize="24" FontAttributes="Bold" TextColor="{DynamicResource AppTextColor}" />
<Label Text="Select a tab below to begin." TextColor="{DynamicResource Gray500}" />
</VerticalStackLayout>
</ContentPage>

View File

@ -1,4 +1,6 @@
using System.Reflection;
using System.Text;
using CommunityToolkit.Maui;
using FrymasterBadgeApp.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@ -9,45 +11,50 @@ public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
AppLogger.Info("MauiProgram: Starting CreateMauiApp");
var builder = MauiApp.CreateBuilder();
#pragma warning disable CA1416 // Validate platform compatibility
builder
.UseMauiCommunityToolkit()
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
fonts.AddFont("LibreBarcode128-Regular.ttf", "BarcodeFont");
fonts.AddFont("LibreBarcode39-Regular.ttf", "BarcodeFont");
});
#pragma warning restore CA1416 // Validate platform compatibility
// 1. Load Configuration
LoadConfiguration(builder);
// 2. Register Services with Factory
builder.Services.AddSingleton<SqlService>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var connectionString = config.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(connectionString))
{
AppLogger.Warn(
"SqlService: Connection string missing from config. Using HARDCODED FALLBACK."
);
// TODO: Replace with your actual SQL Server details
connectionString =
"Server=127.0.0.1;Database=Frymaster;User Id=sa;Password=YourPassword123;TrustServerCertificate=True;";
}
return new SqlService(connectionString);
});
// --- Simplified Configuration Loading ---
// Instead of GetManifestResourceStream, we use the simpler FileSystem approach
// if the file is marked as a MauiAsset.
try
{
var configPath = "appsettings.json";
// Note: MauiAssets in Resources/Raw are accessed at the root of the app package
using var stream = FileSystem.OpenAppPackageFileAsync(configPath).Result;
if (stream != null)
{
var config = new ConfigurationBuilder().AddJsonStream(stream).Build();
builder.Configuration.AddConfiguration(config);
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"CONFIG WARNING: {ex.Message}");
// Don't 'throw' here unless it's truly fatal,
// otherwise the app won't even start.
}
// 1. Register Services
builder.Services.AddSingleton<SqlService>();
builder.Services.AddSingleton<PrinterService>();
// 2. Register Pages (Crucial for constructor injection)
builder.Services.AddTransient<MainPage>();
builder.Services.AddTransient<CompanyPage>();
// 3. Register Shell and Pages
builder.Services.AddSingleton<AppShell>();
builder.Services.AddTransient<EmployeePage>();
builder.Services.AddTransient<CompanyPage>();
builder.Services.AddTransient<SettingsPage>();
#if DEBUG
builder.Logging.AddDebug();
@ -55,4 +62,44 @@ public static class MauiProgram
return builder.Build();
}
private static void LoadConfiguration(MauiAppBuilder builder)
{
try
{
// Standard MAUI way to read Resources\Raw files
using var stream = FileSystem.OpenAppPackageFileAsync("appsettings.json").Result;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var config = new ConfigurationBuilder()
.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json)))
.Build();
builder.Configuration.AddConfiguration(config);
AppLogger.Info("Config: appsettings.json loaded via FileSystem.");
}
catch (Exception ex)
{
AppLogger.Warn(
$"Config: FileSystem load failed ({ex.Message}). Trying Manifest fallback..."
);
try
{
var assembly = Assembly.GetExecutingAssembly();
string resourcePath = "FrymasterBadgeApp.Resources.Raw.appsettings.json";
using var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream != null)
{
var config = new ConfigurationBuilder().AddJsonStream(stream).Build();
builder.Configuration.AddConfiguration(config);
AppLogger.Info("Config: Loaded via Manifest fallback.");
}
}
catch
{ /* Final fallback to hardcoded string in SqlService factory */
}
}
}
}

164
README.md Normal file
View File

@ -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).

View File

@ -1,4 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
<svg
width="456"
height="456"
viewBox="0 0 456 456"
version="1.1"
id="svg1"
sodipodi:docname="appicon.svg"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<inkscape:path-effect
effect="bspline"
id="path-effect9"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect8"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" />
</defs>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.6951754"
inkscape:cx="227.70505"
inkscape:cy="228"
inkscape:window-width="1920"
inkscape:window-height="991"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<rect
style="fill:#ffffff;stroke:#000000;stroke-width:13.9;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="rect2"
width="307.3428"
height="185.23157"
x="72.558861"
y="109.72316" />
<rect
style="fill:#ffffff;stroke:#000000;stroke-width:13.9;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="rect3"
width="81.407501"
height="73.738678"
x="225.3454"
y="198.20956" />
<ellipse
style="fill:#ffffff;stroke:#000000;stroke-width:13.9;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path3"
cx="265.45923"
cy="161.34023"
rx="23.006468"
ry="21.531694" />
<path
style="fill:#ffffff;stroke:#000000;stroke-width:13.9;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 75.508409,176.38292 c 0,0 102.054331,-1.76972 102.054331,-1.76972"
id="path7"
inkscape:path-effect="#path-effect7"
inkscape:original-d="M 75.508409,176.38292 177.56274,174.6132"
transform="translate(23.006469,53.68176)" />
<path
style="fill:#ffffff;stroke:#000000;stroke-width:13.9;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 80.227684,140.98836 c 0,0 81.407506,-0.58991 81.407506,-0.58991"
id="path8"
inkscape:path-effect="#path-effect8"
inkscape:original-d="m 80.227684,140.98836 81.407506,-0.58991"
transform="translate(23.006469,53.68176)" />
<path
style="fill:#ffffff;stroke:#000000;stroke-width:13.9;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 80.227684,106.77361 c 0,0 77.868046,-1.76973 77.868046,-1.76973"
id="path9"
inkscape:path-effect="#path-effect9"
inkscape:original-d="m 80.227684,106.77361 77.868046,-1.76973"
transform="translate(23.006469,53.68176)" />
</svg>

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

BIN
Resources/Raw/Guest.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
Resources/Raw/WelBilt.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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": {

View File

@ -0,0 +1,11 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=ZABEASTPORTABLE\\SQLEXPRESS01;Database=FrymasterBadges;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,27 +1,33 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
>
<Color x:Key="Primary">#2563EB</Color>
<Color x:Key="PrimaryDark">#1E40AF</Color>
<Color x:Key="Secondary">#64748B</Color>
<Color x:Key="Tertiary">#F1F5F9</Color>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Color x:Key="Primary">#2563EB</Color>
<Color x:Key="Secondary">#64748B</Color>
<Color x:Key="Success">#10B981</Color>
<Color x:Key="Danger">#EF4444</Color>
<Color x:Key="Warning">#F59E0B</Color>
<Color x:Key="Info">#3B82F6</Color>
<Color x:Key="PageBgLight">#F9FAFB</Color>
<Color x:Key="PageBgDark">#111827</Color>
<Color x:Key="Black">#000000</Color>
<Color x:Key="Info">#3ABFF8</Color>
<Color x:Key="White">#FFFFFF</Color>
<Color x:Key="Black">#000000</Color>
<Color x:Key="Gray100">#F3F4F6</Color>
<Color x:Key="Gray200">#E5E7EB</Color>
<Color x:Key="Gray500">#6B7280</Color>
<Color x:Key="Gray900">#111827</Color>
<Color x:Key="Tertiary">#2D3748</Color>
<Color x:Key="BgLight">#F9FAFB</Color>
<Color x:Key="TextLight">#111827</Color>
<Color x:Key="CardLight">#FFFFFF</Color>
<Color x:Key="BorderLight">#E5E7EB</Color>
<Color x:Key="EntryLight">#F0F0F0</Color>
<Color x:Key="BgDark">#0F172A</Color>
<Color x:Key="TextDark">#FFFFFF</Color>
<Color x:Key="CardDark">#111827</Color>
<Color x:Key="BorderDark">#374151</Color>
<Color x:Key="EntryDark">#1F2937</Color>
<Color x:Key="PageBg">#F9FAFB</Color>
<Color x:Key="CardBackground">#FFFFFF</Color>
<Color x:Key="CardBorder">#E5E7EB</Color>
<Color x:Key="AppTextColor">#111827</Color>
<Color x:Key="AppBackgroundColor">#FFFFFF</Color>
<Color x:Key="NavigationPrimary">#2563EB</Color>
</ResourceDictionary>

View File

@ -1,32 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Gray900}" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style ApplyToDerivedTypes="True" TargetType="ContentPage">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource BgLight}, Dark={StaticResource BgDark}}" />
</Style>
<Style ApplyToDerivedTypes="True" TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextLight}, Dark={StaticResource TextDark}}" />
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextLight}, Dark={StaticResource TextDark}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource EntryLight}, Dark={StaticResource EntryDark}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
</Style>
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource CardLight}, Dark={StaticResource CardDark}}" />
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource BorderLight}, Dark={StaticResource BorderDark}}" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="StrokeShape" Value="RoundRectangle 10" />
</Style>
<Style x:Key="CardFrame" TargetType="Frame">
<Setter Property="BackgroundColor" Value="{StaticResource White}" />
<Setter Property="BorderColor" Value="{StaticResource Gray200}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource CardLight}, Dark={StaticResource CardDark}}" />
<Setter Property="BorderColor" Value="{AppThemeBinding Light={StaticResource BorderLight}, Dark={StaticResource BorderDark}}" />
<Setter Property="HasShadow" Value="True" />
<Setter Property="Padding" Value="15" />
</Style>
<Style x:Key="EntryStyle" TargetType="Entry">
<Setter Property="TextColor" Value="{StaticResource Gray900}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="BackgroundColor" Value="{StaticResource Gray100}" />
<Setter Property="Margin" Value="0,5" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{StaticResource Primary}" />
<Setter Property="BarTextColor" Value="{StaticResource White}" />
</Style>
</ResourceDictionary>

View File

@ -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<bool> PrintBadge(
Dictionary<string, object> emp,
Dictionary<string, object> 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<string, object> emp,
Dictionary<string, object> 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<string, object> emp,
Dictionary<string, object> 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<List<string>> DiscoverPrinters()
{
return await Task.Run(() =>
{
List<string> 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<Zebra.Sdk.Card.Containers.GraphicsInfo> 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) =>
{
Side = Zebra.Sdk.Card.Enumerations.CardSide.Front,
PrintType = Zebra.Sdk.Card.Enumerations.PrintType.Color,
GraphicData = g.CreateImage(),
}
try
{
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
);
}
cardPrinter.Print(1, info);
return (true, "");
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)
{
return (false, ex.Message);
AppLogger.Error("PrinterService: Error during PrintPage event", ex);
e.HasMorePages = false;
}
finally
};
pd.Print();
AppLogger.Info("PrinterService: Print job sent to spooler.");
}
}
catch (Exception ex)
{
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<DiscoveredPrinter> 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<string, object> dict, string key) =>
dict.TryGetValue(key, out var val) && val != null ? val.ToString()! : "";
}

View File

@ -8,23 +8,40 @@ 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<Dictionary<string, object>> Query(string sql, SqlParameter[]? parameters = null)
{
var rows = new List<Dictionary<string, object>>();
if (string.IsNullOrEmpty(_connectionString))
{
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())
{
@ -35,16 +52,50 @@ public class SqlService
}
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)
{
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();
cmd.ExecuteNonQuery();
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
}
}
}

130
SettingsPage.xaml Normal file
View File

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="FrymasterBadgeApp.SettingsPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
Title="Settings">
<ScrollView>
<VerticalStackLayout Padding="30" Spacing="20">
<Label
FontAttributes="Bold"
FontSize="24"
Text="Configuration"
TextColor="{StaticResource Primary}" />
<VerticalStackLayout Spacing="8">
<Label
FontAttributes="Bold"
FontSize="16"
Text="Theme" />
<Picker
x:Name="ThemePicker"
Title="Select theme"
TextColor="{AppThemeBinding Light={StaticResource TextLight}, Dark={StaticResource TextDark}}">
<Picker.Items>
<x:String>System</x:String>
<x:String>Light</x:String>
<x:String>Dark</x:String>
</Picker.Items>
</Picker>
</VerticalStackLayout>
<BoxView HeightRequest="1"
Color="{AppThemeBinding Light={StaticResource BorderLight}, Dark={StaticResource BorderDark}}" />
<VerticalStackLayout Spacing="10">
<Label
FontAttributes="Bold"
FontSize="16"
Text="Badge Photo Folder" />
<Label
FontSize="12"
Text="Where employee photos are stored"
TextColor="{StaticResource Gray500}" />
<HorizontalStackLayout Spacing="10">
<Entry
x:Name="PathEntry"
IsReadOnly="True"
Placeholder="Select folder..."
FontSize="15"
HorizontalOptions="FillAndExpand" />
<Button
Clicked="OnPickPhotoFolderClicked"
Text="Browse..."
WidthRequest="100" />
</HorizontalStackLayout>
</VerticalStackLayout>
<VerticalStackLayout Margin="0,10,0,0" Spacing="10">
<Label
FontAttributes="Bold"
FontSize="16"
Text="Logo Folder" />
<HorizontalStackLayout Spacing="10">
<Entry
x:Name="LogoPathEntry"
IsReadOnly="True"
Placeholder="Select folder..."
FontSize="15"
HorizontalOptions="FillAndExpand" />
<Button
Clicked="OnPickLogoFolderClicked"
Text="Browse..."
WidthRequest="100" />
</HorizontalStackLayout>
</VerticalStackLayout>
<VerticalStackLayout Margin="0,10,0,0" Spacing="10">
<Label
FontAttributes="Bold"
FontSize="16"
Text="Additional Images Folder" />
<HorizontalStackLayout Spacing="10">
<Entry
x:Name="ImagesPathEntry"
IsReadOnly="True"
Placeholder="Select folder..."
FontSize="15"
HorizontalOptions="FillAndExpand" />
<Button
Clicked="OnPickImagesFolderClicked"
Text="Browse..."
WidthRequest="100" />
</HorizontalStackLayout>
</VerticalStackLayout>
<Button
x:Name="SaveButton"
Margin="0,30,0,0"
BackgroundColor="{StaticResource Primary}"
Clicked="OnSaveClicked"
CornerRadius="8"
HeightRequest="50"
Text="Save Settings"
TextColor="White" />
<Label
x:Name="StatusLabel"
Margin="5,8,0,0"
FontSize="13"
TextColor="{StaticResource Success}" />
<Label
Margin="0,20,0,0"
FontSize="11"
HorizontalOptions="Center"
Text="Tip: Use the Browse buttons to select folders easily"
TextColor="{StaticResource Gray500}" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

230
SettingsPage.xaml.cs Normal file
View File

@ -0,0 +1,230 @@
using CommunityToolkit.Maui.Alerts;
using CommunityToolkit.Maui.Storage;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Storage;
namespace FrymasterBadgeApp;
public partial class SettingsPage : ContentPage
{
private const string PhotoPathKey = "PhotoBasePath";
private const string LogoPathKey = "LogoBasePath";
private const string ImagesPathKey = "ImagesBasePath";
private const string ThemePrefKey = "AppTheme";
public SettingsPage()
{
InitializeComponent();
LoadCurrentSettings();
LoadCurrentThemePreference();
ThemePicker.SelectedIndexChanged += (s, e) => ApplyThemeSelection();
Shell.SetBackButtonBehavior(
this,
new BackButtonBehavior
{
TextOverride = "Back",
Command = new Command(async () => await Shell.Current.GoToAsync("..")),
}
);
}
private void LoadCurrentSettings()
{
PathEntry.Text = Preferences.Default.Get(PhotoPathKey, @"C:\FrymasterData\photos");
LogoPathEntry.Text = Preferences.Default.Get(LogoPathKey, @"C:\FrymasterData\logos");
ImagesPathEntry.Text = Preferences.Default.Get(ImagesPathKey, @"C:\FrymasterData\images");
}
private void LoadCurrentThemePreference()
{
var pref = Preferences.Default.Get(ThemePrefKey, "System");
switch (pref)
{
case "Light":
ThemePicker.SelectedIndex = 1;
Application.Current.UserAppTheme = AppTheme.Light;
break;
case "Dark":
ThemePicker.SelectedIndex = 2;
Application.Current.UserAppTheme = AppTheme.Dark;
break;
default:
ThemePicker.SelectedIndex = 0;
Application.Current.UserAppTheme = AppTheme.Unspecified;
break;
}
}
private void ApplyThemeSelection()
{
if (ThemePicker == null)
return;
var sel = ThemePicker.SelectedIndex;
switch (sel)
{
case 1: // Light
Application.Current.UserAppTheme = AppTheme.Light;
Preferences.Default.Set(ThemePrefKey, "Light");
break;
case 2: // Dark
Application.Current.UserAppTheme = AppTheme.Dark;
Preferences.Default.Set(ThemePrefKey, "Dark");
break;
default: // System
Application.Current.UserAppTheme = AppTheme.Unspecified;
Preferences.Default.Set(ThemePrefKey, "System");
break;
}
}
private async void OnPickPhotoFolderClicked(object sender, EventArgs e) =>
await PickFolderAsync(r => PathEntry.Text = r.Folder.Path, "Select Photos Folder");
private async void OnPickLogoFolderClicked(object sender, EventArgs e) =>
await PickFolderAsync(r => LogoPathEntry.Text = r.Folder.Path, "Select Logos Folder");
private async void OnPickImagesFolderClicked(object sender, EventArgs e) =>
await PickFolderAsync(r => ImagesPathEntry.Text = r.Folder.Path, "Select Images Folder");
private async Task PickFolderAsync(Action<FolderPickerResult> onSuccess, string title)
{
try
{
var result = await FolderPicker.Default.PickAsync(title, CancellationToken.None);
if (result.IsSuccessful)
{
onSuccess(result);
VerifyDirectory(result.Folder.Path);
}
}
catch (Exception ex)
{
await DisplayAlert("Error", ex.Message, "OK");
}
}
private async void OnSaveClicked(object sender, EventArgs e)
{
AppLogger.Info("Save settings clicked - start");
var photoPath = PathEntry?.Text?.Trim();
var logoPath = LogoPathEntry?.Text?.Trim();
var imagesPath = ImagesPathEntry?.Text?.Trim();
AppLogger.Info(
$"Paths collected → Photos: '{photoPath ?? "null"}', Logos: '{logoPath ?? "null"}', Images: '{imagesPath ?? "null"}'"
);
if (
string.IsNullOrWhiteSpace(photoPath)
|| string.IsNullOrWhiteSpace(logoPath)
|| string.IsNullOrWhiteSpace(imagesPath)
)
{
AppLogger.Warning("Validation failed - one or more paths empty");
await DisplayAlert("Error", "All folders must be selected.", "OK");
return;
}
try
{
AppLogger.Info("Saving preferences...");
Preferences.Default.Set(PhotoPathKey, photoPath);
Preferences.Default.Set(LogoPathKey, logoPath);
Preferences.Default.Set(ImagesPathKey, imagesPath);
AppLogger.Info("Preferences saved OK");
StatusLabel.Text = "Settings saved successfully!";
StatusLabel.TextColor = Colors.Green;
// Toast is optional & wrapped — comment out if still crashes
try
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
await Toast
.Make("Settings saved ✓", CommunityToolkit.Maui.Core.ToastDuration.Short)
.Show();
});
}
catch (Exception toastEx)
{
AppLogger.Warn("Toast failed (non-critical)");
}
// NO auto-navigation — let user press back button manually
// This avoids 90% of known Shell navigation crashes
}
catch (Exception ex)
{
AppLogger.Error("Critical save failure", ex);
StatusLabel.Text = $"Save failed: {ex.Message}";
StatusLabel.TextColor = Colors.Red;
await DisplayAlert(
"Save Error",
$"{ex.GetType().Name}: {ex.Message}\n\nCheck logs for details.",
"OK"
);
}
finally
{
AppLogger.Info("Save settings handler finished");
}
}
// private static void SafeCreateDirectory(string? path)
// {
// if (string.IsNullOrWhiteSpace(path)) return;
// try
// {
// if (!Directory.Exists(path))
// {
// AppLogger.Info($"Creating directory: {path}");
// Directory.CreateDirectory(path);
// }
// else
// {
// AppLogger.Info($"Directory already exists: {path}");
// }
// }
// catch (Exception ex)
// {
// AppLogger.Error($"Failed to create/verify directory '{path}'", ex);
// throw; // rethrow so main catch can handle it
// }
// }
// Helper - prevents crash on invalid paths
private static void SafeCreateDirectory(string path)
{
if (string.IsNullOrWhiteSpace(path))
return;
try
{
Directory.CreateDirectory(path);
}
catch (Exception ex)
{
// Log but don't crash the app
System.Diagnostics.Debug.WriteLine(
$"Directory creation failed for '{path}': {ex.Message}"
);
// You could also show a warning here if needed
}
}
private static void VerifyDirectory(string path)
{
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
}
}

295
app.js Normal file
View File

@ -0,0 +1,295 @@
/**
* DeComPress CMS - Core Admin Logic
*/
const state = {
currentView: 'Pages',
themes: [
{
id: 1,
name: 'Minimalist',
version: '1.2.0',
active: true,
color: 'bg-slate-300'
},
{
id: 2,
name: 'Dark Mode Pro',
version: '2.0.1',
active: false,
color: 'bg-gray-800'
},
{
id: 3,
name: 'Ocean Breeze',
version: '1.0.5',
active: false,
color: 'bg-blue-400'
}
],
customFields: [], // Dynamic state for the field builder
navItems: [
{ name: 'Pages', icon: '📄' },
{ name: 'Themes', icon: '🎨' },
{ name: 'Post Types', icon: '📌' },
{ name: 'Custom Fields', icon: '🔧' },
{ name: 'Users', icon: '👥' },
{ name: 'Roles', icon: '🛡️' },
{ name: 'Permissions', icon: '🔑' }
]
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initApp()
})
function initApp () {
renderNav()
navigateTo(state.currentView)
// Mobile Toggle Logic
document.getElementById('mobile-toggle').addEventListener('click', () => {
const sidebar = document.getElementById('sidebar')
sidebar.classList.toggle('hidden')
sidebar.classList.toggle('absolute')
sidebar.classList.toggle('z-50')
sidebar.classList.toggle('h-full')
})
}
// --- Router ---
function navigateTo (viewName) {
state.currentView = viewName
document.getElementById('view-title').innerText = viewName
renderNav() // Refresh active state
const container = document.getElementById('app-content')
container.innerHTML = '' // Clear current
switch (viewName) {
case 'Pages':
container.innerHTML = viewPages()
break
case 'Themes':
container.innerHTML = viewThemes()
break
case 'Permissions':
container.innerHTML = viewPermissions()
break
case 'Custom Fields':
container.innerHTML = viewCustomFields()
break
case 'Users':
container.innerHTML = viewUsers()
break
default:
container.innerHTML = `<div class="p-10 text-center border-2 border-dashed rounded-lg text-gray-400">
${viewName} module is currently under construction.
</div>`
}
}
// --- Views ---
function viewPages () {
return `
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<table class="w-full text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="p-4 font-semibold text-gray-600">Page Title</th>
<th class="p-4 font-semibold text-gray-600">Status</th>
<th class="p-4 font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr><td class="p-4">Homepage</td><td class="p-4"><span class="bg-green-100 text-green-700 px-2 py-1 rounded text-xs">Published</span></td><td class="p-4 text-right text-blue-600 cursor-pointer">Edit</td></tr>
<tr><td class="p-4">Contact</td><td class="p-4"><span class="bg-yellow-100 text-yellow-700 px-2 py-1 rounded text-xs">Draft</span></td><td class="p-4 text-right text-blue-600 cursor-pointer">Edit</td></tr>
</tbody>
</table>
</div>
`
}
function viewThemes () {
return `
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
${state.themes
.map(
t => `
<div class="bg-white rounded-xl shadow-sm border-2 ${
t.active ? 'border-blue-500' : 'border-transparent'
} overflow-hidden">
<div class="h-32 ${t.color}"></div>
<div class="p-4">
<h4 class="font-bold">${t.name}</h4>
<p class="text-xs text-gray-500 mb-4">Version ${
t.version
}</p>
<button onclick="handleAction('Theme ${
t.name
} activated')" class="w-full py-2 rounded text-sm font-medium ${
t.active
? 'bg-blue-50 text-blue-600'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}">
${t.active ? 'Active' : 'Activate'}
</button>
</div>
</div>
`
)
.join('')}
</div>
`
}
function viewPermissions () {
const roles = ['Admin', 'Editor', 'Subscriber']
const caps = ['Update Core', 'Manage Themes', 'Delete Posts', 'Write Posts']
return `
<div class="bg-white rounded-xl shadow-sm overflow-hidden border border-gray-200">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="p-4 text-gray-600">Capability</th>
${roles
.map(
r =>
`<th class="p-4 text-center text-gray-600">${r}</th>`
)
.join('')}
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
${caps
.map(
c => `
<tr>
<td class="p-4 font-medium">${c}</td>
${roles
.map(
r =>
`<td class="p-4 text-center"><input type="checkbox" ${
r === 'Admin' ? 'checked' : ''
} class="w-4 h-4 rounded border-gray-300"></td>`
)
.join('')}
</tr>
`
)
.join('')}
</tbody>
</table>
</div>
</div>
`
}
function viewCustomFields () {
return `
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold">Field Group: Blog Metadata</h3>
<button onclick="addField()" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">+ Add Field</button>
</div>
<div id="fields-container" class="space-y-4">
${
state.customFields.length === 0
? '<p class="text-gray-400 italic text-center py-10">No fields added yet. Click "+ Add Field" to begin.</p>'
: ''
}
</div>
</div>
`
}
function viewUsers () {
return `
<div class="bg-white p-6 rounded-xl shadow-sm max-w-lg border border-gray-200">
<h3 class="font-bold mb-4">Invite New User</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Email Address</label>
<input type="email" id="u-email" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<button onclick="handleUserInvite()" class="bg-slate-900 text-white w-full py-2 rounded-md font-medium hover:bg-black transition">Send Invitation</button>
</div>
</div>
`
}
// --- Component Logic ---
function renderNav () {
const nav = document.getElementById('main-nav')
nav.innerHTML = state.navItems
.map(
item => `
<button onclick="navigateTo('${item.name}')"
class="w-full flex items-center px-4 py-3 rounded-lg text-sm transition-colors ${
state.currentView === item.name
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:bg-slate-800 hover:text-white'
}">
<span class="mr-3 text-lg">${item.icon}</span>
<span class="font-medium">${item.name}</span>
</button>
`
)
.join('')
}
function addField () {
const id = Date.now()
state.customFields.push({ id, label: '', type: 'text' })
refreshFields()
}
function removeField (id) {
state.customFields = state.customFields.filter(f => f.id !== id)
refreshFields()
}
function refreshFields () {
const container = document.getElementById('fields-container')
if (!container) return
container.innerHTML = state.customFields
.map(
(field, index) => `
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm flex items-center space-x-4 animate-in fade-in duration-300">
<div class="text-gray-300 cursor-move"></div>
<div class="flex-1 grid grid-cols-2 gap-4">
<input type="text" placeholder="Field Label" class="border rounded p-2 text-sm" value="${field.label}">
<select class="border rounded p-2 text-sm">
<option>Text</option>
<option>Textarea</option>
<option>Image</option>
<option>Boolean (Toggle)</option>
</select>
</div>
<button onclick="removeField(${field.id})" class="text-red-400 hover:text-red-600 p-2"></button>
</div>
`
)
.join('')
}
// --- General Handlers ---
function handleUserInvite () {
const email = document.getElementById('u-email').value
if (!email.includes('@')) {
alert('Please enter a valid email address.')
return
}
alert(`Invitation sent to ${email}`)
}
function handleAction (msg) {
console.log('CMS Action:', msg)
alert(msg)
}