using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IOnboardingService { Task GetOrCreateAsync(); Task GetWizardAsync(); Task ShouldShowOnboardingAsync(); Task GetDashboardNudgeAsync(); Task ShouldShowWordCompanionHeaderAsync(); Task MoveToNextStepAsync(); Task SetCurrentStepAsync(string currentStep); Task SaveWritingJourneyAsync(string writingJourney); Task SaveWritingSoftwareAsync(string writingSoftware); Task MarkCompletedAsync(); } public sealed class OnboardingService(IOnboardingRepository onboarding, ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) { WritingJourneyValues.PlanningNewStory, WritingJourneyValues.AlreadyStarted, WritingJourneyValues.ManuscriptMostlyComplete }; private static readonly HashSet WritingSoftware = new(StringComparer.Ordinal) { WritingSoftwareValues.MicrosoftWord, WritingSoftwareValues.GoogleDocs, WritingSoftwareValues.Scrivener, WritingSoftwareValues.LibreOfficeOpenOffice, WritingSoftwareValues.MarkdownObsidian, WritingSoftwareValues.Other }; public async Task GetOrCreateAsync() => await onboarding.GetOrCreateAsync(RequireUserId()); public async Task GetWizardAsync() { var state = await GetOrCreateAsync(); var step = ResolveStep(state); return new OnboardingWizardViewModel { CurrentStep = step, StepNumber = StepNumber(step), WritingJourney = state.WritingJourney, WritingSoftware = state.WritingSoftware, Options = step switch { OnboardingSteps.WritingJourney => WritingJourneyOptions(), OnboardingSteps.WritingSoftware => WritingSoftwareOptions(), _ => [] } }; } public async Task ShouldShowOnboardingAsync() { if (!currentUser.UserId.HasValue) { return false; } var state = await onboarding.GetAsync(currentUser.UserId.Value); return state?.OnboardingCompletedUtc is null; } public async Task GetDashboardNudgeAsync() => new() { ShouldShow = await ShouldShowOnboardingAsync() }; public async Task ShouldShowWordCompanionHeaderAsync() => currentUser.UserId.HasValue && await onboarding.ShouldShowWordCompanionHeaderAsync(currentUser.UserId.Value); public async Task MoveToNextStepAsync() { var state = await GetOrCreateAsync(); var nextStep = ResolveStep(state) switch { OnboardingSteps.Welcome => OnboardingSteps.WritingJourney, OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware, _ => OnboardingSteps.Complete }; await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep); } public async Task SetCurrentStepAsync(string currentStep) { if (currentStep is not (OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware or OnboardingSteps.Complete)) { throw new InvalidOperationException("Choose a valid onboarding step."); } await onboarding.SetCurrentStepAsync(RequireUserId(), currentStep); } public async Task SaveWritingJourneyAsync(string writingJourney) { if (!WritingJourneys.Contains(writingJourney)) { throw new InvalidOperationException("Choose a writing journey option."); } await onboarding.SaveWritingJourneyAsync(RequireUserId(), writingJourney); } public async Task SaveWritingSoftwareAsync(string writingSoftware) { if (!WritingSoftware.Contains(writingSoftware)) { throw new InvalidOperationException("Choose a writing software option."); } await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware); } public async Task MarkCompletedAsync() => await onboarding.MarkCompletedAsync(RequireUserId()); private int RequireUserId() => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use onboarding."); private static string ResolveStep(UserOnboardingState state) { if (state.OnboardingCompletedUtc.HasValue) { return OnboardingSteps.Complete; } if (state.CurrentStep is OnboardingSteps.Welcome or OnboardingSteps.WritingJourney or OnboardingSteps.WritingSoftware) { return state.CurrentStep; } if (string.IsNullOrWhiteSpace(state.WritingJourney)) { return OnboardingSteps.WritingJourney; } if (string.IsNullOrWhiteSpace(state.WritingSoftware)) { return OnboardingSteps.WritingSoftware; } return OnboardingSteps.Complete; } private static int StepNumber(string step) => step switch { OnboardingSteps.Welcome => 1, OnboardingSteps.WritingJourney => 2, OnboardingSteps.WritingSoftware => 3, _ => 3 }; private static IReadOnlyList WritingJourneyOptions() => [ new() { Value = WritingJourneyValues.PlanningNewStory, Title = "I am planning a brand new story", Description = "You are shaping the idea, structure, characters, or world before drafting." }, new() { Value = WritingJourneyValues.AlreadyStarted, Title = "I have already started writing", Description = "You have draft material and want PlotDirector to help you organise what exists." }, new() { Value = WritingJourneyValues.ManuscriptMostlyComplete, Title = "My manuscript is largely complete", Description = "You are ready to review structure, continuity, and story detail across the manuscript." } ]; private static IReadOnlyList WritingSoftwareOptions() => [ new() { Value = WritingSoftwareValues.MicrosoftWord, Title = "Microsoft Word", Description = "Best fit for the PlotDirector Word Companion workflow.", Badge = "Recommended" }, new() { Value = WritingSoftwareValues.GoogleDocs, Title = "Google Docs", Description = "Use exports later when manuscript import is added." }, new() { Value = WritingSoftwareValues.Scrivener, Title = "Scrivener", Description = "Use a manuscript export later when import support arrives." }, new() { Value = WritingSoftwareValues.LibreOfficeOpenOffice, Title = "LibreOffice / OpenOffice", Description = "Good for document-based workflows outside Microsoft Word." }, new() { Value = WritingSoftwareValues.MarkdownObsidian, Title = "Markdown / Obsidian", Description = "For plain-text writing systems and linked-note workflows." }, new() { Value = WritingSoftwareValues.Other, Title = "Other", Description = "PlotDirector can still tailor future setup around your tools." } ]; }