From cdc5e0a4ebecdc102194df665dbf884b70b29ad2 Mon Sep 17 00:00:00 2001 From: Jeff Barnard <32176237+JeffBarnard@users.noreply.github.com> Date: Mon, 23 Dec 2024 22:17:28 -0500 Subject: [PATCH] Add project files. --- App.xaml | 15 + App.xaml.cs | 15 + AppShell.xaml | 44 + AppShell.xaml.cs | 51 + BRTDataTools.App.csproj | 78 + BRTDataTools.App.sln | 22 + Data/CategoryRepository.cs | 185 + Data/Constants.cs | 10 + Data/JsonContext.cs | 11 + Data/ProjectRepository.cs | 213 + Data/SeedDataService.cs | 102 + Data/TagRepository.cs | 272 + Data/TaskRespository.cs | 217 + GlobalUsings.cs | 6 + MauiProgram.cs | 48 + Models/Category.cs | 22 + Models/CategoryChartData.cs | 14 + Models/Project.cs | 28 + Models/ProjectTask.cs | 14 + Models/ProjectsTags.cs | 9 + Models/Tag.cs | 51 + PageModels/IProjectTaskPageModel.cs | 11 + PageModels/MainPageModel.cs | 174 + PageModels/ManageMetaPageModel.cs | 106 + PageModels/ProjectDetailPageModel.cs | 273 + PageModels/ProjectListPageModel.cs | 38 + PageModels/TaskDetailPageModel.cs | 174 + Pages/Controls/AddButton.xaml | 12 + Pages/Controls/AddButton.xaml.cs | 10 + Pages/Controls/CategoryChart.xaml | 51 + Pages/Controls/CategoryChart.xaml.cs | 10 + Pages/Controls/LegendExt.cs | 12 + Pages/Controls/ProjectCardView.xaml | 60 + Pages/Controls/ProjectCardView.xaml.cs | 10 + Pages/Controls/TagView.xaml | 18 + Pages/Controls/TagView.xaml.cs | 10 + Pages/Controls/TaskView.xaml | 50 + Pages/Controls/TaskView.xaml.cs | 39 + Pages/MainPage.xaml | 85 + Pages/MainPage.xaml.cs | 14 + Pages/ManageMetaPage.xaml | 117 + Pages/ManageMetaPage.xaml.cs | 11 + Pages/ProjectDetailPage.xaml | 182 + Pages/ProjectDetailPage.xaml.cs | 25 + Pages/ProjectListPage.xaml | 41 + Pages/ProjectListPage.xaml.cs | 11 + Pages/TaskDetailPage.xaml | 53 + Pages/TaskDetailPage.xaml.cs | 11 + Platforms/Android/AndroidManifest.xml | 6 + Platforms/Android/MainActivity.cs | 11 + Platforms/Android/MainApplication.cs | 16 + Platforms/Android/Resources/values/colors.xml | 6 + Platforms/MacCatalyst/AppDelegate.cs | 10 + Platforms/MacCatalyst/Entitlements.plist | 14 + Platforms/MacCatalyst/Info.plist | 38 + Platforms/MacCatalyst/Program.cs | 16 + Platforms/Tizen/Main.cs | 17 + Platforms/Tizen/tizen-manifest.xml | 15 + Platforms/Windows/App.xaml | 8 + Platforms/Windows/App.xaml.cs | 25 + Platforms/Windows/Package.appxmanifest | 46 + Platforms/Windows/app.manifest | 15 + Platforms/iOS/AppDelegate.cs | 10 + Platforms/iOS/Info.plist | 32 + Platforms/iOS/Program.cs | 16 + Platforms/iOS/Resources/PrivacyInfo.xcprivacy | 51 + Properties/launchSettings.json | 8 + Resources/AppIcon/appicon.svg | 4 + Resources/AppIcon/appiconfg.svg | 8 + Resources/Fonts/FluentSystemIcons-Regular.ttf | Bin 0 -> 2299560 bytes Resources/Fonts/FluentUI.cs | 7921 +++++++++++++++++ Resources/Fonts/OpenSans-Regular.ttf | Bin 0 -> 107280 bytes Resources/Fonts/OpenSans-Semibold.ttf | Bin 0 -> 111184 bytes Resources/Fonts/SegoeUI-Semibold.ttf | Bin 0 -> 870356 bytes Resources/Images/dotnet_bot.png | Bin 0 -> 93437 bytes Resources/Raw/AboutAssets.txt | 15 + Resources/Raw/SeedData.json | 61 + Resources/Splash/splash.svg | 8 + Resources/Styles/AppStyles.xaml | 289 + Resources/Styles/Colors.xaml | 53 + Resources/Styles/Styles.xaml | 457 + Services/IErrorHandler.cs | 14 + Services/ModalErrorHandler.cs | 33 + Utilities/ProjectExtentions.cs | 21 + Utilities/TaskUtilities.cs | 27 + 85 files changed, 12306 insertions(+) create mode 100644 App.xaml create mode 100644 App.xaml.cs create mode 100644 AppShell.xaml create mode 100644 AppShell.xaml.cs create mode 100644 BRTDataTools.App.csproj create mode 100644 BRTDataTools.App.sln create mode 100644 Data/CategoryRepository.cs create mode 100644 Data/Constants.cs create mode 100644 Data/JsonContext.cs create mode 100644 Data/ProjectRepository.cs create mode 100644 Data/SeedDataService.cs create mode 100644 Data/TagRepository.cs create mode 100644 Data/TaskRespository.cs create mode 100644 GlobalUsings.cs create mode 100644 MauiProgram.cs create mode 100644 Models/Category.cs create mode 100644 Models/CategoryChartData.cs create mode 100644 Models/Project.cs create mode 100644 Models/ProjectTask.cs create mode 100644 Models/ProjectsTags.cs create mode 100644 Models/Tag.cs create mode 100644 PageModels/IProjectTaskPageModel.cs create mode 100644 PageModels/MainPageModel.cs create mode 100644 PageModels/ManageMetaPageModel.cs create mode 100644 PageModels/ProjectDetailPageModel.cs create mode 100644 PageModels/ProjectListPageModel.cs create mode 100644 PageModels/TaskDetailPageModel.cs create mode 100644 Pages/Controls/AddButton.xaml create mode 100644 Pages/Controls/AddButton.xaml.cs create mode 100644 Pages/Controls/CategoryChart.xaml create mode 100644 Pages/Controls/CategoryChart.xaml.cs create mode 100644 Pages/Controls/LegendExt.cs create mode 100644 Pages/Controls/ProjectCardView.xaml create mode 100644 Pages/Controls/ProjectCardView.xaml.cs create mode 100644 Pages/Controls/TagView.xaml create mode 100644 Pages/Controls/TagView.xaml.cs create mode 100644 Pages/Controls/TaskView.xaml create mode 100644 Pages/Controls/TaskView.xaml.cs create mode 100644 Pages/MainPage.xaml create mode 100644 Pages/MainPage.xaml.cs create mode 100644 Pages/ManageMetaPage.xaml create mode 100644 Pages/ManageMetaPage.xaml.cs create mode 100644 Pages/ProjectDetailPage.xaml create mode 100644 Pages/ProjectDetailPage.xaml.cs create mode 100644 Pages/ProjectListPage.xaml create mode 100644 Pages/ProjectListPage.xaml.cs create mode 100644 Pages/TaskDetailPage.xaml create mode 100644 Pages/TaskDetailPage.xaml.cs create mode 100644 Platforms/Android/AndroidManifest.xml create mode 100644 Platforms/Android/MainActivity.cs create mode 100644 Platforms/Android/MainApplication.cs create mode 100644 Platforms/Android/Resources/values/colors.xml create mode 100644 Platforms/MacCatalyst/AppDelegate.cs create mode 100644 Platforms/MacCatalyst/Entitlements.plist create mode 100644 Platforms/MacCatalyst/Info.plist create mode 100644 Platforms/MacCatalyst/Program.cs create mode 100644 Platforms/Tizen/Main.cs create mode 100644 Platforms/Tizen/tizen-manifest.xml create mode 100644 Platforms/Windows/App.xaml create mode 100644 Platforms/Windows/App.xaml.cs create mode 100644 Platforms/Windows/Package.appxmanifest create mode 100644 Platforms/Windows/app.manifest create mode 100644 Platforms/iOS/AppDelegate.cs create mode 100644 Platforms/iOS/Info.plist create mode 100644 Platforms/iOS/Program.cs create mode 100644 Platforms/iOS/Resources/PrivacyInfo.xcprivacy create mode 100644 Properties/launchSettings.json create mode 100644 Resources/AppIcon/appicon.svg create mode 100644 Resources/AppIcon/appiconfg.svg create mode 100644 Resources/Fonts/FluentSystemIcons-Regular.ttf create mode 100644 Resources/Fonts/FluentUI.cs create mode 100644 Resources/Fonts/OpenSans-Regular.ttf create mode 100644 Resources/Fonts/OpenSans-Semibold.ttf create mode 100644 Resources/Fonts/SegoeUI-Semibold.ttf create mode 100644 Resources/Images/dotnet_bot.png create mode 100644 Resources/Raw/AboutAssets.txt create mode 100644 Resources/Raw/SeedData.json create mode 100644 Resources/Splash/splash.svg create mode 100644 Resources/Styles/AppStyles.xaml create mode 100644 Resources/Styles/Colors.xaml create mode 100644 Resources/Styles/Styles.xaml create mode 100644 Services/IErrorHandler.cs create mode 100644 Services/ModalErrorHandler.cs create mode 100644 Utilities/ProjectExtentions.cs create mode 100644 Utilities/TaskUtilities.cs diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..293d0d6 --- /dev/null +++ b/App.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..bbe66f2 --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,15 @@ +namespace BRTDataTools.App +{ + public partial class App : Application + { + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new AppShell()); + } + } +} \ No newline at end of file diff --git a/AppShell.xaml b/AppShell.xaml new file mode 100644 index 0000000..563007b --- /dev/null +++ b/AppShell.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AppShell.xaml.cs b/AppShell.xaml.cs new file mode 100644 index 0000000..63d9ad6 --- /dev/null +++ b/AppShell.xaml.cs @@ -0,0 +1,51 @@ +using CommunityToolkit.Maui.Alerts; +using CommunityToolkit.Maui.Core; +using Font = Microsoft.Maui.Font; + +namespace BRTDataTools.App +{ + public partial class AppShell : Shell + { + public AppShell() + { + InitializeComponent(); + var currentTheme = Application.Current!.UserAppTheme; + ThemeSegmentedControl.SelectedIndex = currentTheme == AppTheme.Light ? 0 : 1; + } + public static async Task DisplaySnackbarAsync(string message) + { + CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + + var snackbarOptions = new SnackbarOptions + { + BackgroundColor = Color.FromArgb("#FF3300"), + TextColor = Colors.White, + ActionButtonTextColor = Colors.Yellow, + CornerRadius = new CornerRadius(0), + Font = Font.SystemFontOfSize(18), + ActionButtonFont = Font.SystemFontOfSize(14) + }; + + var snackbar = Snackbar.Make(message, visualOptions: snackbarOptions); + + await snackbar.Show(cancellationTokenSource.Token); + } + + public static async Task DisplayToastAsync(string message) + { + // Toast is currently not working in MCT on Windows + if (OperatingSystem.IsWindows()) + return; + + var toast = Toast.Make(message, textSize: 18); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await toast.Show(cts.Token); + } + + private void SfSegmentedControl_SelectionChanged(object sender, Syncfusion.Maui.Toolkit.SegmentedControl.SelectionChangedEventArgs e) + { + Application.Current!.UserAppTheme = e.NewIndex == 0 ? AppTheme.Light : AppTheme.Dark; + } + } +} diff --git a/BRTDataTools.App.csproj b/BRTDataTools.App.csproj new file mode 100644 index 0000000..2ad88cf --- /dev/null +++ b/BRTDataTools.App.csproj @@ -0,0 +1,78 @@ + + + + net9.0-android;net9.0-ios;net9.0-maccatalyst + $(TargetFrameworks);net9.0-windows10.0.19041.0 + + + + + + + Exe + BRTDataTools.App + true + true + enable + enable + + XC0103 + true + + + BRTDataTools.App + + + com.companyname.brtdatatools.app + + + 1.0 + 1 + + + None + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + 6.5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BRTDataTools.App.sln b/BRTDataTools.App.sln new file mode 100644 index 0000000..0fe1083 --- /dev/null +++ b/BRTDataTools.App.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 d17.12 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BRTDataTools.App", "BRTDataTools.App.csproj", "{6234D526-0A18-43AF-A895-15EDEF616353}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6234D526-0A18-43AF-A895-15EDEF616353}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6234D526-0A18-43AF-A895-15EDEF616353}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6234D526-0A18-43AF-A895-15EDEF616353}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6234D526-0A18-43AF-A895-15EDEF616353}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Data/CategoryRepository.cs b/Data/CategoryRepository.cs new file mode 100644 index 0000000..77547be --- /dev/null +++ b/Data/CategoryRepository.cs @@ -0,0 +1,185 @@ +using BRTDataTools.App.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace BRTDataTools.App.Data +{ + /// + /// Repository class for managing categories in the database. + /// + public class CategoryRepository + { + private bool _hasBeenInitialized = false; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public CategoryRepository(ILogger logger) + { + _logger = logger; + } + + /// + /// Initializes the database connection and creates the Category table if it does not exist. + /// + private async Task Init() + { + if (_hasBeenInitialized) + return; + + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + try + { + var createTableCmd = connection.CreateCommand(); + createTableCmd.CommandText = @" + CREATE TABLE IF NOT EXISTS Category ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + Title TEXT NOT NULL, + Color TEXT NOT NULL + );"; + await createTableCmd.ExecuteNonQueryAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error creating Category table"); + throw; + } + + _hasBeenInitialized = true; + } + + /// + /// Retrieves a list of all categories from the database. + /// + /// A list of objects. + public async Task> ListAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Category"; + var categories = new List(); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + categories.Add(new Category + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + Color = reader.GetString(2) + }); + } + + return categories; + } + + /// + /// Retrieves a specific category by its ID. + /// + /// The ID of the category. + /// A object if found; otherwise, null. + public async Task GetAsync(int id) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Category WHERE ID = @id"; + selectCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return new Category + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + Color = reader.GetString(2) + }; + } + + return null; + } + + /// + /// Saves a category to the database. If the category ID is 0, a new category is created; otherwise, the existing category is updated. + /// + /// The category to save. + /// The ID of the saved category. + public async Task SaveItemAsync(Category item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var saveCmd = connection.CreateCommand(); + if (item.ID == 0) + { + saveCmd.CommandText = @" + INSERT INTO Category (Title, Color) + VALUES (@Title, @Color); + SELECT last_insert_rowid();"; + } + else + { + saveCmd.CommandText = @" + UPDATE Category SET Title = @Title, Color = @Color + WHERE ID = @ID"; + saveCmd.Parameters.AddWithValue("@ID", item.ID); + } + + saveCmd.Parameters.AddWithValue("@Title", item.Title); + saveCmd.Parameters.AddWithValue("@Color", item.Color); + + var result = await saveCmd.ExecuteScalarAsync(); + if (item.ID == 0) + { + item.ID = Convert.ToInt32(result); + } + + return item.ID; + } + + /// + /// Deletes a category from the database. + /// + /// The category to delete. + /// The number of rows affected. + public async Task DeleteItemAsync(Category item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM Category WHERE ID = @id"; + deleteCmd.Parameters.AddWithValue("@id", item.ID); + + return await deleteCmd.ExecuteNonQueryAsync(); + } + + /// + /// Drops the Category table from the database. + /// + public async Task DropTableAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var dropTableCmd = connection.CreateCommand(); + dropTableCmd.CommandText = "DROP TABLE IF EXISTS Category"; + + await dropTableCmd.ExecuteNonQueryAsync(); + _hasBeenInitialized = false; + } + } +} \ No newline at end of file diff --git a/Data/Constants.cs b/Data/Constants.cs new file mode 100644 index 0000000..be235f6 --- /dev/null +++ b/Data/Constants.cs @@ -0,0 +1,10 @@ +namespace BRTDataTools.App.Data +{ + public static class Constants + { + public const string DatabaseFilename = "AppSQLite.db3"; + + public static string DatabasePath => + $"Data Source={Path.Combine(FileSystem.AppDataDirectory, DatabaseFilename)}"; + } +} \ No newline at end of file diff --git a/Data/JsonContext.cs b/Data/JsonContext.cs new file mode 100644 index 0000000..f118a45 --- /dev/null +++ b/Data/JsonContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using BRTDataTools.App.Models; + +[JsonSerializable(typeof(Project))] +[JsonSerializable(typeof(ProjectTask))] +[JsonSerializable(typeof(ProjectsJson))] +[JsonSerializable(typeof(Category))] +[JsonSerializable(typeof(Tag))] +public partial class JsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Data/ProjectRepository.cs b/Data/ProjectRepository.cs new file mode 100644 index 0000000..d5c6b69 --- /dev/null +++ b/Data/ProjectRepository.cs @@ -0,0 +1,213 @@ +using BRTDataTools.App.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace BRTDataTools.App.Data +{ + /// + /// Repository class for managing projects in the database. + /// + public class ProjectRepository + { + private bool _hasBeenInitialized = false; + private readonly ILogger _logger; + private readonly TaskRepository _taskRepository; + private readonly TagRepository _tagRepository; + + /// + /// Initializes a new instance of the class. + /// + /// The task repository instance. + /// The tag repository instance. + /// The logger instance. + public ProjectRepository(TaskRepository taskRepository, TagRepository tagRepository, ILogger logger) + { + _taskRepository = taskRepository; + _tagRepository = tagRepository; + _logger = logger; + } + + /// + /// Initializes the database connection and creates the Project table if it does not exist. + /// + private async Task Init() + { + if (_hasBeenInitialized) + return; + + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + try + { + var createTableCmd = connection.CreateCommand(); + createTableCmd.CommandText = @" + CREATE TABLE IF NOT EXISTS Project ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL, + Description TEXT NOT NULL, + Icon TEXT NOT NULL, + CategoryID INTEGER NOT NULL + );"; + await createTableCmd.ExecuteNonQueryAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error creating Project table"); + throw; + } + + _hasBeenInitialized = true; + } + + /// + /// Retrieves a list of all projects from the database. + /// + /// A list of objects. + public async Task> ListAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Project"; + var projects = new List(); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + projects.Add(new Project + { + ID = reader.GetInt32(0), + Name = reader.GetString(1), + Description = reader.GetString(2), + Icon = reader.GetString(3), + CategoryID = reader.GetInt32(4) + }); + } + + foreach (var project in projects) + { + project.Tags = await _tagRepository.ListAsync(project.ID); + project.Tasks = await _taskRepository.ListAsync(project.ID); + } + + return projects; + } + + /// + /// Retrieves a specific project by its ID. + /// + /// The ID of the project. + /// A object if found; otherwise, null. + public async Task GetAsync(int id) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Project WHERE ID = @id"; + selectCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + var project = new Project + { + ID = reader.GetInt32(0), + Name = reader.GetString(1), + Description = reader.GetString(2), + Icon = reader.GetString(3), + CategoryID = reader.GetInt32(4) + }; + + project.Tags = await _tagRepository.ListAsync(project.ID); + project.Tasks = await _taskRepository.ListAsync(project.ID); + + return project; + } + + return null; + } + + /// + /// Saves a project to the database. If the project ID is 0, a new project is created; otherwise, the existing project is updated. + /// + /// The project to save. + /// The ID of the saved project. + public async Task SaveItemAsync(Project item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var saveCmd = connection.CreateCommand(); + if (item.ID == 0) + { + saveCmd.CommandText = @" + INSERT INTO Project (Name, Description, Icon, CategoryID) + VALUES (@Name, @Description, @Icon, @CategoryID); + SELECT last_insert_rowid();"; + } + else + { + saveCmd.CommandText = @" + UPDATE Project + SET Name = @Name, Description = @Description, Icon = @Icon, CategoryID = @CategoryID + WHERE ID = @ID"; + saveCmd.Parameters.AddWithValue("@ID", item.ID); + } + + saveCmd.Parameters.AddWithValue("@Name", item.Name); + saveCmd.Parameters.AddWithValue("@Description", item.Description); + saveCmd.Parameters.AddWithValue("@Icon", item.Icon); + saveCmd.Parameters.AddWithValue("@CategoryID", item.CategoryID); + + var result = await saveCmd.ExecuteScalarAsync(); + if (item.ID == 0) + { + item.ID = Convert.ToInt32(result); + } + + return item.ID; + } + + /// + /// Deletes a project from the database. + /// + /// The project to delete. + /// The number of rows affected. + public async Task DeleteItemAsync(Project item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM Project WHERE ID = @ID"; + deleteCmd.Parameters.AddWithValue("@ID", item.ID); + + return await deleteCmd.ExecuteNonQueryAsync(); + } + + /// + /// Drops the Project table from the database. + /// + public async Task DropTableAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var dropCmd = connection.CreateCommand(); + dropCmd.CommandText = "DROP TABLE IF EXISTS Project"; + await dropCmd.ExecuteNonQueryAsync(); + + await _taskRepository.DropTableAsync(); + await _tagRepository.DropTableAsync(); + _hasBeenInitialized = false; + } + } +} \ No newline at end of file diff --git a/Data/SeedDataService.cs b/Data/SeedDataService.cs new file mode 100644 index 0000000..b24b859 --- /dev/null +++ b/Data/SeedDataService.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using BRTDataTools.App.Models; +using Microsoft.Extensions.Logging; + +namespace BRTDataTools.App.Data +{ + public class SeedDataService + { + private readonly ProjectRepository _projectRepository; + private readonly TaskRepository _taskRepository; + private readonly TagRepository _tagRepository; + private readonly CategoryRepository _categoryRepository; + private readonly string _seedDataFilePath = "SeedData.json"; + private readonly ILogger _logger; + + public SeedDataService(ProjectRepository projectRepository, TaskRepository taskRepository, TagRepository tagRepository, CategoryRepository categoryRepository, ILogger logger) + { + _projectRepository = projectRepository; + _taskRepository = taskRepository; + _tagRepository = tagRepository; + _categoryRepository = categoryRepository; + _logger = logger; + } + + public async Task LoadSeedDataAsync() + { + ClearTables(); + + await using Stream templateStream = await FileSystem.OpenAppPackageFileAsync(_seedDataFilePath); + + ProjectsJson? payload = null; + try + { + payload = JsonSerializer.Deserialize(templateStream, JsonContext.Default.ProjectsJson); + } + catch (Exception e) + { + _logger.LogError(e, "Error deserializing seed data"); + } + + try + { + if (payload is not null) + { + foreach (var project in payload.Projects) + { + if (project is null) + { + continue; + } + + if (project.Category is not null) + { + await _categoryRepository.SaveItemAsync(project.Category); + project.CategoryID = project.Category.ID; + } + + await _projectRepository.SaveItemAsync(project); + + if (project?.Tasks is not null) + { + foreach (var task in project.Tasks) + { + task.ProjectID = project.ID; + await _taskRepository.SaveItemAsync(task); + } + } + + if (project?.Tags is not null) + { + foreach (var tag in project.Tags) + { + await _tagRepository.SaveItemAsync(tag, project.ID); + } + } + } + } + } + catch (Exception e) + { + _logger.LogError(e, "Error saving seed data"); + throw; + } + } + + private async void ClearTables() + { + try + { + await Task.WhenAll( + _projectRepository.DropTableAsync(), + _taskRepository.DropTableAsync(), + _tagRepository.DropTableAsync(), + _categoryRepository.DropTableAsync()); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + } +} \ No newline at end of file diff --git a/Data/TagRepository.cs b/Data/TagRepository.cs new file mode 100644 index 0000000..6cbf4e3 --- /dev/null +++ b/Data/TagRepository.cs @@ -0,0 +1,272 @@ +using BRTDataTools.App.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace BRTDataTools.App.Data +{ + /// + /// Repository class for managing tags in the database. + /// + public class TagRepository + { + private bool _hasBeenInitialized = false; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public TagRepository(ILogger logger) + { + _logger = logger; + } + + /// + /// Initializes the database connection and creates the Tag and ProjectsTags tables if they do not exist. + /// + private async Task Init() + { + if (_hasBeenInitialized) + return; + + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + try + { + var createTableCmd = connection.CreateCommand(); + createTableCmd.CommandText = @" + CREATE TABLE IF NOT EXISTS Tag ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + Title TEXT NOT NULL, + Color TEXT NOT NULL + );"; + await createTableCmd.ExecuteNonQueryAsync(); + + createTableCmd.CommandText = @" + CREATE TABLE IF NOT EXISTS ProjectsTags ( + ProjectID INTEGER NOT NULL, + TagID INTEGER NOT NULL, + PRIMARY KEY(ProjectID, TagID) + );"; + await createTableCmd.ExecuteNonQueryAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error creating tables"); + throw; + } + + _hasBeenInitialized = true; + } + + /// + /// Retrieves a list of all tags from the database. + /// + /// A list of objects. + public async Task> ListAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Tag"; + var tags = new List(); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tags.Add(new Tag + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + Color = reader.GetString(2) + }); + } + + return tags; + } + + /// + /// Retrieves a list of tags associated with a specific project. + /// + /// The ID of the project. + /// A list of objects. + public async Task> ListAsync(int projectID) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = @" + SELECT t.* + FROM Tag t + JOIN ProjectsTags pt ON t.ID = pt.TagID + WHERE pt.ProjectID = @ProjectID"; + selectCmd.Parameters.AddWithValue("ProjectID", projectID); + + var tags = new List(); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tags.Add(new Tag + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + Color = reader.GetString(2) + }); + } + + return tags; + } + + /// + /// Retrieves a specific tag by its ID. + /// + /// The ID of the tag. + /// A object if found; otherwise, null. + public async Task GetAsync(int id) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Tag WHERE ID = @id"; + selectCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return new Tag + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + Color = reader.GetString(2) + }; + } + + return null; + } + + /// + /// Saves a tag to the database. If the tag ID is 0, a new tag is created; otherwise, the existing tag is updated. + /// + /// The tag to save. + /// The ID of the saved tag. + public async Task SaveItemAsync(Tag item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var saveCmd = connection.CreateCommand(); + if (item.ID == 0) + { + saveCmd.CommandText = @" + INSERT INTO Tag (Title, Color) VALUES (@Title, @Color); + SELECT last_insert_rowid();"; + } + else + { + saveCmd.CommandText = @" + UPDATE Tag SET Title = @Title, Color = @Color WHERE ID = @ID"; + saveCmd.Parameters.AddWithValue("@ID", item.ID); + } + + saveCmd.Parameters.AddWithValue("@Title", item.Title); + saveCmd.Parameters.AddWithValue("@Color", item.Color); + + var result = await saveCmd.ExecuteScalarAsync(); + if (item.ID == 0) + { + item.ID = Convert.ToInt32(result); + } + + return item.ID; + } + + /// + /// Saves a tag to the database and associates it with a specific project. + /// + /// The tag to save. + /// The ID of the project. + /// The number of rows affected. + public async Task SaveItemAsync(Tag item, int projectID) + { + await Init(); + await SaveItemAsync(item); + + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var saveCmd = connection.CreateCommand(); + saveCmd.CommandText = @" + INSERT INTO ProjectsTags (ProjectID, TagID) VALUES (@projectID, @tagID)"; + saveCmd.Parameters.AddWithValue("@projectID", projectID); + saveCmd.Parameters.AddWithValue("@tagID", item.ID); + + return await saveCmd.ExecuteNonQueryAsync(); + } + + /// + /// Deletes a tag from the database. + /// + /// The tag to delete. + /// The number of rows affected. + public async Task DeleteItemAsync(Tag item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM Tag WHERE ID = @id"; + deleteCmd.Parameters.AddWithValue("@id", item.ID); + + return await deleteCmd.ExecuteNonQueryAsync(); + } + + /// + /// Deletes a tag from a specific project in the database. + /// + /// The tag to delete. + /// The ID of the project. + /// The number of rows affected. + public async Task DeleteItemAsync(Tag item, int projectID) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM ProjectsTags WHERE ProjectID = @projectID AND TagID = @tagID"; + deleteCmd.Parameters.AddWithValue("@projectID", projectID); + deleteCmd.Parameters.AddWithValue("@tagID", item.ID); + + return await deleteCmd.ExecuteNonQueryAsync(); + } + + /// + /// Drops the Tag and ProjectsTags tables from the database. + /// + public async Task DropTableAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var dropTableCmd = connection.CreateCommand(); + dropTableCmd.CommandText = "DROP TABLE IF EXISTS Tag"; + await dropTableCmd.ExecuteNonQueryAsync(); + + dropTableCmd.CommandText = "DROP TABLE IF EXISTS ProjectsTags"; + await dropTableCmd.ExecuteNonQueryAsync(); + + _hasBeenInitialized = false; + } + } +} \ No newline at end of file diff --git a/Data/TaskRespository.cs b/Data/TaskRespository.cs new file mode 100644 index 0000000..b4c280f --- /dev/null +++ b/Data/TaskRespository.cs @@ -0,0 +1,217 @@ +using BRTDataTools.App.Models; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace BRTDataTools.App.Data +{ + /// + /// Repository class for managing tasks in the database. + /// + public class TaskRepository + { + private bool _hasBeenInitialized = false; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public TaskRepository(ILogger logger) + { + _logger = logger; + } + + /// + /// Initializes the database connection and creates the Task table if it does not exist. + /// + private async Task Init() + { + if (_hasBeenInitialized) + return; + + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + try + { + var createTableCmd = connection.CreateCommand(); + createTableCmd.CommandText = @" + CREATE TABLE IF NOT EXISTS Task ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + Title TEXT NOT NULL, + IsCompleted INTEGER NOT NULL, + ProjectID INTEGER NOT NULL + );"; + await createTableCmd.ExecuteNonQueryAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error creating Task table"); + throw; + } + + _hasBeenInitialized = true; + } + + /// + /// Retrieves a list of all tasks from the database. + /// + /// A list of objects. + public async Task> ListAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Task"; + var tasks = new List(); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tasks.Add(new ProjectTask + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + IsCompleted = reader.GetBoolean(2), + ProjectID = reader.GetInt32(3) + }); + } + + return tasks; + } + + /// + /// Retrieves a list of tasks associated with a specific project. + /// + /// The ID of the project. + /// A list of objects. + public async Task> ListAsync(int projectId) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Task WHERE ProjectID = @projectId"; + selectCmd.Parameters.AddWithValue("@projectId", projectId); + var tasks = new List(); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tasks.Add(new ProjectTask + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + IsCompleted = reader.GetBoolean(2), + ProjectID = reader.GetInt32(3) + }); + } + + return tasks; + } + + /// + /// Retrieves a specific task by its ID. + /// + /// The ID of the task. + /// A object if found; otherwise, null. + public async Task GetAsync(int id) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var selectCmd = connection.CreateCommand(); + selectCmd.CommandText = "SELECT * FROM Task WHERE ID = @id"; + selectCmd.Parameters.AddWithValue("@id", id); + + await using var reader = await selectCmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return new ProjectTask + { + ID = reader.GetInt32(0), + Title = reader.GetString(1), + IsCompleted = reader.GetBoolean(2), + ProjectID = reader.GetInt32(3) + }; + } + + return null; + } + + /// + /// Saves a task to the database. If the task ID is 0, a new task is created; otherwise, the existing task is updated. + /// + /// The task to save. + /// The ID of the saved task. + public async Task SaveItemAsync(ProjectTask item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var saveCmd = connection.CreateCommand(); + if (item.ID == 0) + { + saveCmd.CommandText = @" + INSERT INTO Task (Title, IsCompleted, ProjectID) VALUES (@title, @isCompleted, @projectId); + SELECT last_insert_rowid();"; + } + else + { + saveCmd.CommandText = @" + UPDATE Task SET Title = @title, IsCompleted = @isCompleted, ProjectID = @projectId WHERE ID = @id"; + saveCmd.Parameters.AddWithValue("@id", item.ID); + } + + saveCmd.Parameters.AddWithValue("@title", item.Title); + saveCmd.Parameters.AddWithValue("@isCompleted", item.IsCompleted); + saveCmd.Parameters.AddWithValue("@projectId", item.ProjectID); + + var result = await saveCmd.ExecuteScalarAsync(); + if (item.ID == 0) + { + item.ID = Convert.ToInt32(result); + } + + return item.ID; + } + + /// + /// Deletes a task from the database. + /// + /// The task to delete. + /// The number of rows affected. + public async Task DeleteItemAsync(ProjectTask item) + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var deleteCmd = connection.CreateCommand(); + deleteCmd.CommandText = "DELETE FROM Task WHERE ID = @id"; + deleteCmd.Parameters.AddWithValue("@id", item.ID); + + return await deleteCmd.ExecuteNonQueryAsync(); + } + + /// + /// Drops the Task table from the database. + /// + public async Task DropTableAsync() + { + await Init(); + await using var connection = new SqliteConnection(Constants.DatabasePath); + await connection.OpenAsync(); + + var dropTableCmd = connection.CreateCommand(); + dropTableCmd.CommandText = "DROP TABLE IF EXISTS Task"; + await dropTableCmd.ExecuteNonQueryAsync(); + _hasBeenInitialized = false; + } + } +} \ No newline at end of file diff --git a/GlobalUsings.cs b/GlobalUsings.cs new file mode 100644 index 0000000..017e94b --- /dev/null +++ b/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using BRTDataTools.App.Data; +global using BRTDataTools.App.PageModels; +global using BRTDataTools.App.Pages; +global using BRTDataTools.App.Services; +global using BRTDataTools.App.Utilities; +global using Fonts; diff --git a/MauiProgram.cs b/MauiProgram.cs new file mode 100644 index 0000000..1108a8f --- /dev/null +++ b/MauiProgram.cs @@ -0,0 +1,48 @@ +using CommunityToolkit.Maui; +using Microsoft.Extensions.Logging; +using Syncfusion.Maui.Toolkit.Hosting; + +namespace BRTDataTools.App +{ + public static class MauiProgram + { + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .UseMauiCommunityToolkit() + .ConfigureSyncfusionToolkit() + .ConfigureMauiHandlers(handlers => + { + }) + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + fonts.AddFont("SegoeUI-Semibold.ttf", "SegoeSemibold"); + fonts.AddFont("FluentSystemIcons-Regular.ttf", FluentUI.FontFamily); + }); + +#if DEBUG + builder.Logging.AddDebug(); + builder.Services.AddLogging(configure => configure.AddDebug()); +#endif + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddTransientWithShellRoute("project"); + builder.Services.AddTransientWithShellRoute("task"); + + return builder.Build(); + } + } +} diff --git a/Models/Category.cs b/Models/Category.cs new file mode 100644 index 0000000..ba680bb --- /dev/null +++ b/Models/Category.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace BRTDataTools.App.Models +{ + public class Category + { + public int ID { get; set; } + public string Title { get; set; } = string.Empty; + public string Color { get; set; } = "#FF0000"; + + [JsonIgnore] + public Brush ColorBrush + { + get + { + return new SolidColorBrush(Microsoft.Maui.Graphics.Color.FromArgb(Color)); + } + } + + public override string ToString() => $"{Title}"; + } +} \ No newline at end of file diff --git a/Models/CategoryChartData.cs b/Models/CategoryChartData.cs new file mode 100644 index 0000000..f09a3df --- /dev/null +++ b/Models/CategoryChartData.cs @@ -0,0 +1,14 @@ +namespace BRTDataTools.App.Models +{ + public class CategoryChartData + { + public string Title { get; set; } = string.Empty; + public int Count { get; set; } + + public CategoryChartData(string title, int count) + { + Title = title; + Count = count; + } + } +} \ No newline at end of file diff --git a/Models/Project.cs b/Models/Project.cs new file mode 100644 index 0000000..3435da4 --- /dev/null +++ b/Models/Project.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace BRTDataTools.App.Models +{ + public class Project + { + public int ID { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + + [JsonIgnore] + public int CategoryID { get; set; } + + public Category? Category { get; set; } + + public List Tasks { get; set; } = []; + + public List Tags { get; set; } = []; + + public override string ToString() => $"{Name}"; + } + + public class ProjectsJson + { + public List Projects { get; set; } = []; + } +} \ No newline at end of file diff --git a/Models/ProjectTask.cs b/Models/ProjectTask.cs new file mode 100644 index 0000000..1dbea1a --- /dev/null +++ b/Models/ProjectTask.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace BRTDataTools.App.Models +{ + public class ProjectTask + { + public int ID { get; set; } + public string Title { get; set; } = string.Empty; + public bool IsCompleted { get; set; } + + [JsonIgnore] + public int ProjectID { get; set; } + } +} \ No newline at end of file diff --git a/Models/ProjectsTags.cs b/Models/ProjectsTags.cs new file mode 100644 index 0000000..dce2a95 --- /dev/null +++ b/Models/ProjectsTags.cs @@ -0,0 +1,9 @@ +namespace BRTDataTools.App.Models +{ + public class ProjectsTags + { + public int ID { get; set; } + public int ProjectID { get; set; } + public int TagID { get; set; } + } +} \ No newline at end of file diff --git a/Models/Tag.cs b/Models/Tag.cs new file mode 100644 index 0000000..0cc9ae5 --- /dev/null +++ b/Models/Tag.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; +using CommunityToolkit.Maui.Core.Extensions; + +namespace BRTDataTools.App.Models +{ + public class Tag + { + public int ID { get; set; } + public string Title { get; set; } = string.Empty; + public string Color { get; set; } = "#FF0000"; + + [JsonIgnore] + public Brush ColorBrush + { + get + { + return new SolidColorBrush(Microsoft.Maui.Graphics.Color.FromArgb(Color)); + } + } + + [JsonIgnore] + public Color DisplayColor + { + get + { + return Microsoft.Maui.Graphics.Color.FromArgb(Color); + } + } + + [JsonIgnore] + public Color DisplayDarkColor + { + get + { + return DisplayColor.WithBlackKey(0.8); + } + } + + [JsonIgnore] + public Color DisplayLightColor + { + get + { + return DisplayColor.WithBlackKey(0.2); + } + } + + [JsonIgnore] + public bool IsSelected { get; set; } + } +} \ No newline at end of file diff --git a/PageModels/IProjectTaskPageModel.cs b/PageModels/IProjectTaskPageModel.cs new file mode 100644 index 0000000..ac5b6fc --- /dev/null +++ b/PageModels/IProjectTaskPageModel.cs @@ -0,0 +1,11 @@ +using BRTDataTools.App.Models; +using CommunityToolkit.Mvvm.Input; + +namespace BRTDataTools.App.PageModels +{ + public interface IProjectTaskPageModel + { + IAsyncRelayCommand NavigateToTaskCommand { get; } + bool IsBusy { get; } + } +} \ No newline at end of file diff --git a/PageModels/MainPageModel.cs b/PageModels/MainPageModel.cs new file mode 100644 index 0000000..629adfe --- /dev/null +++ b/PageModels/MainPageModel.cs @@ -0,0 +1,174 @@ +using BRTDataTools.App.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BRTDataTools.App.PageModels +{ + public partial class MainPageModel : ObservableObject, IProjectTaskPageModel + { + private bool _isNavigatedTo; + private bool _dataLoaded; + private readonly ProjectRepository _projectRepository; + private readonly TaskRepository _taskRepository; + private readonly CategoryRepository _categoryRepository; + private readonly ModalErrorHandler _errorHandler; + private readonly SeedDataService _seedDataService; + + [ObservableProperty] + private List _todoCategoryData = []; + + [ObservableProperty] + private List _todoCategoryColors = []; + + [ObservableProperty] + private List _tasks = []; + + [ObservableProperty] + private List _projects = []; + + [ObservableProperty] + bool _isBusy; + + [ObservableProperty] + bool _isRefreshing; + + [ObservableProperty] + private string _today = DateTime.Now.ToString("dddd, MMM d"); + + public bool HasCompletedTasks + => Tasks?.Any(t => t.IsCompleted) ?? false; + + public MainPageModel(SeedDataService seedDataService, ProjectRepository projectRepository, + TaskRepository taskRepository, CategoryRepository categoryRepository, ModalErrorHandler errorHandler) + { + _projectRepository = projectRepository; + _taskRepository = taskRepository; + _categoryRepository = categoryRepository; + _errorHandler = errorHandler; + _seedDataService = seedDataService; + } + + private async Task LoadData() + { + try + { + IsBusy = true; + + Projects = await _projectRepository.ListAsync(); + + var chartData = new List(); + var chartColors = new List(); + + var categories = await _categoryRepository.ListAsync(); + foreach (var category in categories) + { + chartColors.Add(category.ColorBrush); + + var ps = Projects.Where(p => p.CategoryID == category.ID).ToList(); + int tasksCount = ps.SelectMany(p => p.Tasks).Count(); + + chartData.Add(new(category.Title, tasksCount)); + } + + TodoCategoryData = chartData; + TodoCategoryColors = chartColors; + + Tasks = await _taskRepository.ListAsync(); + } + finally + { + IsBusy = false; + OnPropertyChanged(nameof(HasCompletedTasks)); + } + } + + private async Task InitData(SeedDataService seedDataService) + { + bool isSeeded = Preferences.Default.ContainsKey("is_seeded"); + + if (!isSeeded) + { + await seedDataService.LoadSeedDataAsync(); + } + + Preferences.Default.Set("is_seeded", true); + await Refresh(); + } + + [RelayCommand] + private async Task Refresh() + { + try + { + IsRefreshing = true; + await LoadData(); + } + catch (Exception e) + { + _errorHandler.HandleError(e); + } + finally + { + IsRefreshing = false; + } + } + + [RelayCommand] + private void NavigatedTo() => + _isNavigatedTo = true; + + [RelayCommand] + private void NavigatedFrom() => + _isNavigatedTo = false; + + [RelayCommand] + private async Task Appearing() + { + if (!_dataLoaded) + { + await InitData(_seedDataService); + _dataLoaded = true; + await Refresh(); + } + // This means we are being navigated to + else if (!_isNavigatedTo) + { + await Refresh(); + } + } + + [RelayCommand] + private Task TaskCompleted(ProjectTask task) + { + OnPropertyChanged(nameof(HasCompletedTasks)); + return _taskRepository.SaveItemAsync(task); + } + + [RelayCommand] + private Task AddTask() + => Shell.Current.GoToAsync($"task"); + + [RelayCommand] + private Task NavigateToProject(Project project) + => Shell.Current.GoToAsync($"project?id={project.ID}"); + + [RelayCommand] + private Task NavigateToTask(ProjectTask task) + => Shell.Current.GoToAsync($"task?id={task.ID}"); + + [RelayCommand] + private async Task CleanTasks() + { + var completedTasks = Tasks.Where(t => t.IsCompleted).ToList(); + foreach (var task in completedTasks) + { + await _taskRepository.DeleteItemAsync(task); + Tasks.Remove(task); + } + + OnPropertyChanged(nameof(HasCompletedTasks)); + Tasks = new(Tasks); + await AppShell.DisplayToastAsync("All cleaned up!"); + } + } +} \ No newline at end of file diff --git a/PageModels/ManageMetaPageModel.cs b/PageModels/ManageMetaPageModel.cs new file mode 100644 index 0000000..21c17b9 --- /dev/null +++ b/PageModels/ManageMetaPageModel.cs @@ -0,0 +1,106 @@ +using System.Collections.ObjectModel; +using BRTDataTools.App.Data; +using BRTDataTools.App.Models; +using BRTDataTools.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BRTDataTools.App.PageModels +{ + public partial class ManageMetaPageModel : ObservableObject + { + private readonly CategoryRepository _categoryRepository; + private readonly TagRepository _tagRepository; + private readonly SeedDataService _seedDataService; + + [ObservableProperty] + private ObservableCollection _categories = []; + + [ObservableProperty] + private ObservableCollection _tags = []; + + public ManageMetaPageModel(CategoryRepository categoryRepository, TagRepository tagRepository, SeedDataService seedDataService) + { + _categoryRepository = categoryRepository; + _tagRepository = tagRepository; + _seedDataService = seedDataService; + } + + private async Task LoadData() + { + var categoriesList = await _categoryRepository.ListAsync(); + Categories = new ObservableCollection(categoriesList); + var tagsList = await _tagRepository.ListAsync(); + Tags = new ObservableCollection(tagsList); + } + + [RelayCommand] + private Task Appearing() + => LoadData(); + + [RelayCommand] + private async Task SaveCategories() + { + foreach (var category in Categories) + { + await _categoryRepository.SaveItemAsync(category); + } + + await AppShell.DisplayToastAsync("Categories saved"); + } + + [RelayCommand] + private async Task DeleteCategory(Category category) + { + Categories.Remove(category); + await _categoryRepository.DeleteItemAsync(category); + await AppShell.DisplayToastAsync("Category deleted"); + } + + [RelayCommand] + private async Task AddCategory() + { + var category = new Category(); + Categories.Add(category); + await _categoryRepository.SaveItemAsync(category); + await AppShell.DisplayToastAsync("Category added"); + } + + [RelayCommand] + private async Task SaveTags() + { + foreach (var tag in Tags) + { + await _tagRepository.SaveItemAsync(tag); + } + + await AppShell.DisplayToastAsync("Tags saved"); + } + + [RelayCommand] + private async Task DeleteTag(Tag tag) + { + Tags.Remove(tag); + await _tagRepository.DeleteItemAsync(tag); + await AppShell.DisplayToastAsync("Tag deleted"); + } + + [RelayCommand] + private async Task AddTag() + { + var tag = new Tag(); + Tags.Add(tag); + await _tagRepository.SaveItemAsync(tag); + await AppShell.DisplayToastAsync("Tag added"); + } + + [RelayCommand] + private async Task Reset() + { + Preferences.Default.Remove("is_seeded"); + await _seedDataService.LoadSeedDataAsync(); + Preferences.Default.Set("is_seeded", true); + await Shell.Current.GoToAsync("//main"); + } + } +} diff --git a/PageModels/ProjectDetailPageModel.cs b/PageModels/ProjectDetailPageModel.cs new file mode 100644 index 0000000..9853e0b --- /dev/null +++ b/PageModels/ProjectDetailPageModel.cs @@ -0,0 +1,273 @@ +using BRTDataTools.App.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BRTDataTools.App.PageModels +{ + public partial class ProjectDetailPageModel : ObservableObject, IQueryAttributable, IProjectTaskPageModel + { + private Project? _project; + private readonly ProjectRepository _projectRepository; + private readonly TaskRepository _taskRepository; + private readonly CategoryRepository _categoryRepository; + private readonly TagRepository _tagRepository; + private readonly ModalErrorHandler _errorHandler; + + [ObservableProperty] + private string _name = string.Empty; + + [ObservableProperty] + private string _description = string.Empty; + + [ObservableProperty] + private List _tasks = []; + + [ObservableProperty] + private List _categories = []; + + [ObservableProperty] + private Category? _category; + + [ObservableProperty] + private int _categoryIndex = -1; + + [ObservableProperty] + private List _allTags = []; + + [ObservableProperty] + private string _icon = FluentUI.ribbon_24_regular; + + [ObservableProperty] + bool _isBusy; + + [ObservableProperty] + private List _icons = + [ + FluentUI.ribbon_24_regular, + FluentUI.ribbon_star_24_regular, + FluentUI.trophy_24_regular, + FluentUI.badge_24_regular, + FluentUI.book_24_regular, + FluentUI.people_24_regular, + FluentUI.bot_24_regular + ]; + + public bool HasCompletedTasks + => _project?.Tasks.Any(t => t.IsCompleted) ?? false; + + public ProjectDetailPageModel(ProjectRepository projectRepository, TaskRepository taskRepository, CategoryRepository categoryRepository, TagRepository tagRepository, ModalErrorHandler errorHandler) + { + _projectRepository = projectRepository; + _taskRepository = taskRepository; + _categoryRepository = categoryRepository; + _tagRepository = tagRepository; + _errorHandler = errorHandler; + + Tasks = []; + } + + public void ApplyQueryAttributes(IDictionary query) + { + if (query.ContainsKey("id")) + { + int id = Convert.ToInt32(query["id"]); + LoadData(id).FireAndForgetSafeAsync(_errorHandler); + } + else if (query.ContainsKey("refresh")) + { + RefreshData().FireAndForgetSafeAsync(_errorHandler); + } + else + { + Task.WhenAll(LoadCategories(), LoadTags()).FireAndForgetSafeAsync(_errorHandler); + _project = new(); + _project.Tags = []; + _project.Tasks = []; + Tasks = _project.Tasks; + } + } + + private async Task LoadCategories() => + Categories = await _categoryRepository.ListAsync(); + + private async Task LoadTags() => + AllTags = await _tagRepository.ListAsync(); + + private async Task RefreshData() + { + if (_project.IsNullOrNew()) + { + if (_project is not null) + Tasks = new(_project.Tasks); + + return; + } + + Tasks = await _taskRepository.ListAsync(_project.ID); + _project.Tasks = Tasks; + } + + private async Task LoadData(int id) + { + try + { + IsBusy = true; + + _project = await _projectRepository.GetAsync(id); + + if (_project.IsNullOrNew()) + { + _errorHandler.HandleError(new Exception($"Project with id {id} could not be found.")); + return; + } + + Name = _project.Name; + Description = _project.Description; + Tasks = _project.Tasks; + + Icon = _project.Icon; + + Categories = await _categoryRepository.ListAsync(); + Category = Categories?.FirstOrDefault(c => c.ID == _project.CategoryID); + CategoryIndex = Categories?.FindIndex(c => c.ID == _project.CategoryID) ?? -1; + + var allTags = await _tagRepository.ListAsync(); + foreach (var tag in allTags) + { + tag.IsSelected = _project.Tags.Any(t => t.ID == tag.ID); + } + AllTags = new(allTags); + } + catch (Exception e) + { + _errorHandler.HandleError(e); + } + finally + { + IsBusy = false; + OnPropertyChanged(nameof(HasCompletedTasks)); + } + } + + [RelayCommand] + private async Task TaskCompleted(ProjectTask task) + { + await _taskRepository.SaveItemAsync(task); + OnPropertyChanged(nameof(HasCompletedTasks)); + } + + + [RelayCommand] + private async Task Save() + { + if (_project is null) + { + _errorHandler.HandleError( + new Exception("Project is null. Cannot Save.")); + + return; + } + + _project.Name = Name; + _project.Description = Description; + _project.CategoryID = Category?.ID ?? 0; + _project.Icon = Icon ?? FluentUI.ribbon_24_regular; + await _projectRepository.SaveItemAsync(_project); + + if (_project.IsNullOrNew()) + { + foreach (var tag in AllTags) + { + if (tag.IsSelected) + { + await _tagRepository.SaveItemAsync(tag, _project.ID); + } + } + } + + foreach (var task in _project.Tasks) + { + if (task.ID == 0) + { + task.ProjectID = _project.ID; + await _taskRepository.SaveItemAsync(task); + } + } + + await Shell.Current.GoToAsync(".."); + await AppShell.DisplayToastAsync("Project saved"); + } + + [RelayCommand] + private async Task AddTask() + { + if (_project is null) + { + _errorHandler.HandleError( + new Exception("Project is null. Cannot navigate to task.")); + + return; + } + + // Pass the project so if this is a new project we can just add + // the tasks to the project and then save them all from here. + await Shell.Current.GoToAsync($"task", + new ShellNavigationQueryParameters(){ + {TaskDetailPageModel.ProjectQueryKey, _project} + }); + } + + [RelayCommand] + private async Task Delete() + { + if (_project.IsNullOrNew()) + { + await Shell.Current.GoToAsync(".."); + return; + } + + await _projectRepository.DeleteItemAsync(_project); + await Shell.Current.GoToAsync(".."); + await AppShell.DisplayToastAsync("Project deleted"); + } + + [RelayCommand] + private Task NavigateToTask(ProjectTask task) => + Shell.Current.GoToAsync($"task?id={task.ID}"); + + [RelayCommand] + private async Task ToggleTag(Tag tag) + { + tag.IsSelected = !tag.IsSelected; + + if (!_project.IsNullOrNew()) + { + if (tag.IsSelected) + { + await _tagRepository.SaveItemAsync(tag, _project.ID); + } + else + { + await _tagRepository.DeleteItemAsync(tag, _project.ID); + } + } + + AllTags = new(AllTags); + } + + [RelayCommand] + private async Task CleanTasks() + { + var completedTasks = Tasks.Where(t => t.IsCompleted).ToArray(); + foreach (var task in completedTasks) + { + await _taskRepository.DeleteItemAsync(task); + Tasks.Remove(task); + } + + Tasks = new(Tasks); + OnPropertyChanged(nameof(HasCompletedTasks)); + await AppShell.DisplayToastAsync("All cleaned up!"); + } + } +} diff --git a/PageModels/ProjectListPageModel.cs b/PageModels/ProjectListPageModel.cs new file mode 100644 index 0000000..d8c4dd5 --- /dev/null +++ b/PageModels/ProjectListPageModel.cs @@ -0,0 +1,38 @@ +#nullable disable +using BRTDataTools.App.Data; +using BRTDataTools.App.Models; +using BRTDataTools.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BRTDataTools.App.PageModels +{ + public partial class ProjectListPageModel : ObservableObject + { + private readonly ProjectRepository _projectRepository; + + [ObservableProperty] + private List _projects = []; + + public ProjectListPageModel(ProjectRepository projectRepository) + { + _projectRepository = projectRepository; + } + + [RelayCommand] + private async Task Appearing() + { + Projects = await _projectRepository.ListAsync(); + } + + [RelayCommand] + Task NavigateToProject(Project project) + => Shell.Current.GoToAsync($"project?id={project.ID}"); + + [RelayCommand] + async Task AddProject() + { + await Shell.Current.GoToAsync($"project"); + } + } +} \ No newline at end of file diff --git a/PageModels/TaskDetailPageModel.cs b/PageModels/TaskDetailPageModel.cs new file mode 100644 index 0000000..021a834 --- /dev/null +++ b/PageModels/TaskDetailPageModel.cs @@ -0,0 +1,174 @@ +using BRTDataTools.App.Data; +using BRTDataTools.App.Models; +using BRTDataTools.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace BRTDataTools.App.PageModels +{ + public partial class TaskDetailPageModel : ObservableObject, IQueryAttributable + { + public const string ProjectQueryKey = "project"; + private ProjectTask? _task; + private bool _canDelete; + private readonly ProjectRepository _projectRepository; + private readonly TaskRepository _taskRepository; + private readonly ModalErrorHandler _errorHandler; + + [ObservableProperty] + private string _title = string.Empty; + + [ObservableProperty] + private bool _isCompleted; + + [ObservableProperty] + private List _projects = []; + + [ObservableProperty] + private Project? _project; + + [ObservableProperty] + private int _selectedProjectIndex = -1; + + + [ObservableProperty] + private bool _isExistingProject; + + public TaskDetailPageModel(ProjectRepository projectRepository, TaskRepository taskRepository, ModalErrorHandler errorHandler) + { + _projectRepository = projectRepository; + _taskRepository = taskRepository; + _errorHandler = errorHandler; + } + + public void ApplyQueryAttributes(IDictionary query) + { + LoadTaskAsync(query).FireAndForgetSafeAsync(_errorHandler); + } + + private async Task LoadTaskAsync(IDictionary query) + { + if (query.TryGetValue(ProjectQueryKey, out var project)) + Project = (Project)project; + + int taskId = 0; + + if (query.ContainsKey("id")) + { + taskId = Convert.ToInt32(query["id"]); + _task = await _taskRepository.GetAsync(taskId); + + if (_task is null) + { + _errorHandler.HandleError(new Exception($"Task Id {taskId} isn't valid.")); + return; + } + + Project = await _projectRepository.GetAsync(_task.ProjectID); + } + else + { + _task = new ProjectTask(); + } + + // If the project is new, we don't need to load the project dropdown + if (Project?.ID == 0) + { + IsExistingProject = false; + } + else + { + Projects = await _projectRepository.ListAsync(); + IsExistingProject = true; + } + + if (Project is not null) + SelectedProjectIndex = Projects.FindIndex(p => p.ID == Project.ID); + else if (_task?.ProjectID > 0) + SelectedProjectIndex = Projects.FindIndex(p => p.ID == _task.ProjectID); + + if (taskId > 0) + { + if (_task is null) + { + _errorHandler.HandleError(new Exception($"Task with id {taskId} could not be found.")); + return; + } + + Title = _task.Title; + IsCompleted = _task.IsCompleted; + CanDelete = true; + } + else + { + _task = new ProjectTask() + { + ProjectID = Project?.ID ?? 0 + }; + } + } + + public bool CanDelete + { + get => _canDelete; + set + { + _canDelete = value; + DeleteCommand.NotifyCanExecuteChanged(); + } + } + + [RelayCommand] + private async Task Save() + { + if (_task is null) + { + _errorHandler.HandleError( + new Exception("Task or project is null. The task could not be saved.")); + + return; + } + + _task.Title = Title; + + int projectId = Project?.ID ?? 0; + + if (Projects.Count > SelectedProjectIndex && SelectedProjectIndex >= 0) + _task.ProjectID = projectId = Projects[SelectedProjectIndex].ID; + + _task.IsCompleted = IsCompleted; + + if (Project?.ID == projectId && !Project.Tasks.Contains(_task)) + Project.Tasks.Add(_task); + + if (_task.ProjectID > 0) + _taskRepository.SaveItemAsync(_task).FireAndForgetSafeAsync(_errorHandler); + + await Shell.Current.GoToAsync("..?refresh=true"); + + if (_task.ID > 0) + await AppShell.DisplayToastAsync("Task saved"); + } + + [RelayCommand(CanExecute = nameof(CanDelete))] + private async Task Delete() + { + if (_task is null || Project is null) + { + _errorHandler.HandleError( + new Exception("Task is null. The task could not be deleted.")); + + return; + } + + if (Project.Tasks.Contains(_task)) + Project.Tasks.Remove(_task); + + if (_task.ID > 0) + await _taskRepository.DeleteItemAsync(_task); + + await Shell.Current.GoToAsync("..?refresh=true"); + await AppShell.DisplayToastAsync("Task deleted"); + } + } +} \ No newline at end of file diff --git a/Pages/Controls/AddButton.xaml b/Pages/Controls/AddButton.xaml new file mode 100644 index 0000000..825abf5 --- /dev/null +++ b/Pages/Controls/AddButton.xaml @@ -0,0 +1,12 @@ + +