< Summary

Information
Class: Pomodoro.Web.Services.ConsentService
Assembly: Pomodoro.Web
File(s): /home/runner/work/Pomodoro/Pomodoro/src/Pomodoro.Web/Services/ConsentService.cs
Line coverage
100%
Covered lines: 216
Uncovered lines: 0
Coverable lines: 216
Total lines: 363
Line coverage: 100%
Branch coverage
100%
Covered branches: 76
Total branches: 76
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Pomodoro/Pomodoro/src/Pomodoro.Web/Services/ConsentService.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Pomodoro.Web.Models;
 3using Pomodoro.Web.Services;
 4using Pomodoro.Web.Services.Repositories;
 5
 6namespace Pomodoro.Web.Services;
 7
 8/// <summary>
 9/// Service for managing the consent modal after timer completion.
 10/// Uses PeriodicTimer for thread-safe async countdown handling.
 11/// Dependencies are injected via constructor for explicit dependency declaration.
 12/// </summary>
 13public class ConsentService : IConsentService, IAsyncDisposable
 14{
 15    private readonly ISessionOptionsService _sessionOptionsService;
 16    private readonly ILogger<ConsentService> _logger;
 17    private bool _isDisposed;
 18    private bool _isInitialized;
 17819    private readonly object _initLock = new();
 20
 21    // PeriodicTimer-based countdown for thread-safe async handling
 22    private PeriodicTimer? _countdownTimer;
 23    private CancellationTokenSource? _countdownCts;
 17824    private readonly object _timerLock = new();
 25
 26    private readonly ITimerService _timerService;
 27    private readonly ITaskService _taskService;
 28    private readonly INotificationService _notificationService;
 29    private readonly AppState _appState;
 30
 31    public event Action? OnConsentRequired;
 32    public event Action? OnCountdownTick;
 33    public event Action? OnConsentHandled;
 34
 69735    public bool IsModalVisible { get; private set; }
 20536    public SessionType CompletedSessionType { get; private set; }
 128437    public int CountdownSeconds { get; private set; }
 31838    public List<ConsentOption> AvailableOptions { get; private set; } = new();
 39
 17840    public ConsentService(
 17841        ITimerService timerService,
 17842        ITaskService taskService,
 17843        INotificationService notificationService,
 17844        AppState appState,
 17845        ISessionOptionsService sessionOptionsService,
 17846        ILogger<ConsentService> logger)
 17847    {
 17848        _timerService = timerService;
 17849        _taskService = taskService;
 17850        _notificationService = notificationService;
 17851        _appState = appState;
 17852        _sessionOptionsService = sessionOptionsService;
 17853        _logger = logger;
 17854    }
 55
 56    /// <summary>
 57    /// Initializes the service - called after all services are created
 58    /// Uses lock to prevent duplicate event subscriptions from multiple Initialize calls
 59    /// </summary>
 60    public void Initialize()
 6161    {
 6162        lock (_initLock)
 6163        {
 6764            if (_isInitialized) return;
 5565            _isInitialized = true;
 5566        }
 67
 5568        if (_timerService != null)
 5569        {
 5570            _timerService.OnTimerComplete += HandleTimerComplete;
 5571        }
 6172    }
 73
 74    private void HandleTimerComplete(SessionType sessionType)
 3975    {
 76        // Use SafeTaskRunner for consistent fire-and-forget handling with error logging
 3977        SafeTaskRunner.RunAndForget(
 3978            () => HandleTimerCompleteSafeAsync(sessionType),
 3979            _logger,
 3980            Constants.SafeTaskOperations.ConsentTimerComplete
 3981        );
 3982    }
 83
 84    /// <summary>
 85    /// Safe async wrapper for HandleTimerComplete to avoid async void issues
 86    /// </summary>
 87    private async Task HandleTimerCompleteSafeAsync(SessionType sessionType)
 3988    {
 89        try
 3990        {
 91            // Play sound and show notification based on user preferences
 3992            await PlayCompletionSoundAndNotifyAsync(sessionType);
 93
 94            // Get settings to check if auto-start is enabled
 3695            var settings = _appState?.Settings;
 96
 97            // Only show consent modal if auto-start is enabled
 98            // When auto-start is disabled, user must manually start the next session
 3699            if (settings?.AutoStartEnabled == true)
 9100            {
 9101                ShowConsentModal(sessionType);
 9102            }
 36103        }
 3104        catch (Exception ex)
 3105        {
 3106            _logger.LogError(ex, Constants.Messages.LogConsentHandleCompleteError);
 3107        }
 39108    }
 109
 110    /// <summary>
 111    /// Plays completion sound and shows notification based on settings
 112    /// </summary>
 113    private async Task PlayCompletionSoundAndNotifyAsync(SessionType sessionType)
 39114    {
 45115        if (!TryGetNotificationSettings(out var settings)) return;
 116
 33117        if (settings.SoundEnabled)
 15118        {
 15119            await PlaySoundAsync(sessionType);
 12120        }
 121
 30122        if (settings.NotificationsEnabled)
 12123        {
 12124            await ShowNotificationAsync(sessionType);
 12125        }
 36126    }
 127
 128    private bool TryGetNotificationSettings(out TimerSettings? settings)
 39129    {
 39130        settings = _appState?.Settings;
 39131        return _notificationService != null && settings != null;
 39132    }
 133
 134    private async Task PlaySoundAsync(SessionType sessionType)
 15135    {
 15136        if (sessionType == SessionType.Pomodoro)
 12137        {
 12138            await _notificationService.PlayTimerCompleteSoundAsync();
 9139        }
 140        else
 3141        {
 3142            await _notificationService.PlayBreakCompleteSoundAsync();
 3143        }
 12144    }
 145
 146    private async Task ShowNotificationAsync(SessionType sessionType)
 12147    {
 12148        if (sessionType == SessionType.Pomodoro)
 9149        {
 9150            await _notificationService.ShowNotificationAsync(
 9151                $"{Constants.SessionTypes.PomodoroEmoji}{Constants.Formatting.EmojiTitleSeparator}{Constants.Messages.Po
 9152                Constants.Messages.PomodoroNotificationMessage,
 9153                sessionType);
 9154        }
 155        else
 3156        {
 3157            await _notificationService.ShowNotificationAsync(
 3158                $"{Constants.SessionTypes.TimerEmoji}{Constants.Formatting.EmojiTitleSeparator}{Constants.Messages.Break
 3159                Constants.Messages.BreakNotificationMessage,
 3160                sessionType);
 3161        }
 12162    }
 163
 164    public void ShowConsentModal(SessionType completedSessionType)
 128165    {
 128166        CompletedSessionType = completedSessionType;
 167
 168        // Get the countdown seconds from user settings
 128169        var settings = _appState?.Settings;
 128170        CountdownSeconds = settings?.AutoStartDelaySeconds ?? Constants.UI.DefaultConsentCountdownSeconds;
 171
 128172        AvailableOptions = _sessionOptionsService.GetOptionsForSessionType(completedSessionType);
 128173        IsModalVisible = true;
 174
 128175        StartCountdown();
 128176        OnConsentRequired?.Invoke();
 128177    }
 178
 179    public async Task SelectOptionAsync(SessionType nextSessionType)
 21180    {
 21181        await HideModalAndStartSessionAsync(nextSessionType);
 21182    }
 183
 184    public async Task HandleTimeoutAsync()
 68185    {
 186        // Get the default option based on completed session type
 68187        var defaultOption = _sessionOptionsService.GetDefaultOption(CompletedSessionType);
 68188        await HideModalAndStartSessionAsync(defaultOption);
 68189    }
 190
 191    public void HideConsentModal()
 22192    {
 22193        StopCountdown();
 22194        IsModalVisible = false;
 22195        OnConsentHandled?.Invoke();
 22196    }
 197
 198    public void RefreshOptions()
 9199    {
 9200        if (IsModalVisible)
 6201        {
 6202            AvailableOptions = _sessionOptionsService.GetOptionsForSessionType(CompletedSessionType);
 6203            OnConsentRequired?.Invoke();
 6204        }
 9205    }
 206
 207    private async Task HideModalAndStartSessionAsync(SessionType sessionType)
 89208    {
 89209        StopCountdown();
 89210        IsModalVisible = false;
 89211        OnConsentHandled?.Invoke();
 212
 99213        if (_timerService == null || _taskService == null) return;
 214
 79215        await StartSessionAsync(sessionType);
 89216    }
 217
 218    private async Task StartSessionAsync(SessionType sessionType)
 79219    {
 79220        switch (sessionType)
 221        {
 222            case SessionType.Pomodoro:
 51223                if (_taskService.CurrentTaskId.HasValue)
 3224                {
 3225                    await _timerService.StartPomodoroAsync(_taskService.CurrentTaskId.Value);
 3226                }
 227                else
 48228                {
 48229                    _logger.LogWarning(Constants.Messages.LogCannotStartPomodoroNoTask);
 48230                }
 51231                break;
 232            case SessionType.ShortBreak:
 25233                await _timerService.StartShortBreakAsync();
 25234                break;
 235            case SessionType.LongBreak:
 3236                await _timerService.StartLongBreakAsync();
 3237                break;
 238        }
 79239    }
 240
 241    #region Countdown Timer Management
 242
 243    /// <summary>
 244    /// Starts the countdown timer using PeriodicTimer for thread-safe async handling
 245    /// </summary>
 246    private void StartCountdown()
 128247    {
 248        // Stop any existing countdown first
 128249        StopCountdown();
 250
 128251        lock (_timerLock)
 128252        {
 131253            if (_isDisposed) return;
 254
 125255            _countdownCts = new CancellationTokenSource();
 125256            _countdownTimer = new PeriodicTimer(TimeSpan.FromSeconds(Constants.Notifications.CountdownIntervalSeconds));
 257
 258            // Run countdown in background - fire-and-forget with proper error handling
 125259            _ = RunCountdownAsync(_countdownCts.Token);
 125260        }
 128261    }
 262
 263    /// <summary>
 264    /// Stops the countdown timer and cleans up resources
 265    /// </summary>
 266    private void StopCountdown()
 311267    {
 311268        lock (_timerLock)
 311269        {
 311270            _countdownCts?.Cancel();
 311271            _countdownCts?.Dispose();
 311272            _countdownCts = null;
 273
 311274            _countdownTimer?.Dispose();
 311275            _countdownTimer = null;
 311276        }
 311277    }
 278
 279    /// <summary>
 280    /// Async countdown loop using PeriodicTimer
 281    /// This runs on the synchronization context when awaited, ensuring thread-safe UI updates
 282    /// </summary>
 283    private async Task RunCountdownAsync(CancellationToken cancellationToken)
 128284    {
 285        try
 128286        {
 287            PeriodicTimer? timer;
 128288            lock (_timerLock)
 128289            {
 128290                timer = _countdownTimer;
 128291            }
 292
 131293            if (timer == null) return;
 294
 442295            while (await timer.WaitForNextTickAsync(cancellationToken))
 382296            {
 382297                if (await ProcessCountdownTickAsync())
 62298                {
 62299                    break;
 300                }
 317301            }
 62302        }
 58303        catch (OperationCanceledException)
 58304        {
 58305        }
 3306        catch (Exception ex)
 3307        {
 3308            _logger.LogDebug(ex, Constants.Messages.LogCountdownError);
 3309        }
 126310    }
 311
 312    private async Task<bool> ProcessCountdownTickAsync()
 383313    {
 383314        lock (_timerLock)
 383315        {
 383316            if (_countdownTimer == null)
 1317            {
 1318                return true;
 319            }
 382320        }
 321
 382322        if (_isDisposed || !IsModalVisible)
 6323        {
 6324            return true;
 325        }
 326
 376327        CountdownSeconds--;
 376328        OnCountdownTick?.Invoke();
 329
 373330        if (CountdownSeconds <= 0)
 56331        {
 56332            StopCountdown();
 333
 56334            if (!_isDisposed)
 56335            {
 56336                await HandleTimeoutAsync();
 56337            }
 56338            return true;
 339        }
 340
 317341        return false;
 380342    }
 343
 344    #endregion
 345
 346    #region IAsyncDisposable
 347
 348    public async ValueTask DisposeAsync()
 16349    {
 16350        _isDisposed = true;
 16351        StopCountdown();
 16352        IsModalVisible = false;
 353
 16354        if (_timerService != null)
 16355        {
 16356            _timerService.OnTimerComplete -= HandleTimerComplete;
 16357        }
 358
 16359        await ValueTask.CompletedTask;
 16360    }
 361
 362    #endregion
 363}