diff --git a/Tests/BatteryTracker.Tests.UnitTests/BatteryTracker.Tests.UnitTests.csproj b/Tests/BatteryTracker.Tests.UnitTests/BatteryTracker.Tests.UnitTests.csproj index 99e8e3d..9e591ef 100644 --- a/Tests/BatteryTracker.Tests.UnitTests/BatteryTracker.Tests.UnitTests.csproj +++ b/Tests/BatteryTracker.Tests.UnitTests/BatteryTracker.Tests.UnitTests.csproj @@ -1,9 +1,10 @@ - net7.0-windows10.0.22000.0 + net8.0-windows10.0.22000.0 BatteryTracker.Tests.UnitTests x86;x64;arm64 + win-x86;win-x64;win-arm64 false enable enable diff --git a/src/App.xaml b/src/App.xaml index e1c99f4..139fe57 100644 --- a/src/App.xaml +++ b/src/App.xaml @@ -12,9 +12,10 @@ - + + diff --git a/src/App.xaml.cs b/src/App.xaml.cs index e611f42..f0aafdf 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -79,6 +79,7 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); // Views and ViewModels services.AddTransient(); @@ -87,6 +88,7 @@ public App() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Taskbar icon services.AddSingleton(); @@ -193,10 +195,26 @@ private async Task InitializeTrayIconAsync() var exitApplicationCommand = (XamlUICommand)Resources["ExitApplicationCommand"]; exitApplicationCommand.ExecuteRequested += ExitApplicationCommand_ExecuteRequested; + var displayBatteryInfoCommand = (XamlUICommand)Resources["DisplayBatteryInfoCommand"]; + displayBatteryInfoCommand.ExecuteRequested += DisplayBatteryInfoCommand_ExecuteRequested; + _batteryIcon = GetService(); await _batteryIcon.InitAsync(MainWindow.BatteryTrayIcon); } + void DisplayBatteryInfoCommand_ExecuteRequested(XamlUICommand sender, ExecuteRequestedEventArgs args) + { + BatteryInfoWindow window = _batteryIcon!.BatteryInfoWindow; + if (window.Visible) + { + window.Hide(); + } + else + { + window.Activate(); + } + } + private void ProcessUnhandledException(Exception? e, bool showNotification) { _logger.LogCritical(e, "Unhandled exception"); diff --git a/src/BatteryTracker.csproj b/src/BatteryTracker.csproj index 3d6298b..a83ef00 100644 --- a/src/BatteryTracker.csproj +++ b/src/BatteryTracker.csproj @@ -1,12 +1,12 @@ WinExe - net7.0-windows10.0.22000.0 + net8.0-windows10.0.22000.0 10.0.17763.0 BatteryTracker app.manifest x86;x64;ARM64 - win10-x86;win10-x64;win10-arm64 + win-x86;win-x64;win-arm64 win10-$(Platform).pubxml true true @@ -98,26 +98,29 @@ + + + - + - - - - - + + + + + - + @@ -144,7 +147,13 @@ $(DefaultXamlRuntime) + + MSBuild:Compile + + + MSBuild:Compile + MSBuild:Compile @@ -160,6 +169,9 @@ $(DefaultXamlRuntime) + + $(DefaultXamlRuntime) + @@ -176,6 +188,9 @@ $(DefaultXamlRuntime) + + $(DefaultXamlRuntime) + diff --git a/src/Contracts/Services/IPowerService.cs b/src/Contracts/Services/IPowerService.cs new file mode 100644 index 0000000..9b56953 --- /dev/null +++ b/src/Contracts/Services/IPowerService.cs @@ -0,0 +1,10 @@ +using BatteryTracker.Models; + +namespace BatteryTracker.Contracts.Services; + +public interface IPowerService +{ + PowerMode GetPowerMode(); + + void SetPowerMode(PowerMode powerMode); +} \ No newline at end of file diff --git a/src/Converters/PowerModeToIndexConverter.cs b/src/Converters/PowerModeToIndexConverter.cs new file mode 100644 index 0000000..831e265 --- /dev/null +++ b/src/Converters/PowerModeToIndexConverter.cs @@ -0,0 +1,19 @@ +using BatteryTracker.Models; +using Microsoft.UI.Xaml.Data; + +namespace BatteryTracker.Converters; + +public class PowerModeToIndexConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + => (double)((PowerMode)value).Mode; + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => (double)value switch + { + 0 => PowerMode.BetterBattery, + 1 => PowerMode.Balanced, + 2 => PowerMode.BestPerformance, + _ => PowerMode.Balanced + }; +} diff --git a/src/Helpers/PowerHelper.cs b/src/Helpers/PowerHelper.cs new file mode 100644 index 0000000..aa8b4b6 --- /dev/null +++ b/src/Helpers/PowerHelper.cs @@ -0,0 +1,21 @@ +/* + * Attribute should be given to this StackOverflow post: + * https://stackoverflow.com/questions/61869347/control-windows-10s-power-mode-programmatically + */ + +using System.Runtime.InteropServices; + +namespace BatteryTracker.Helpers +{ + internal static class PowerHelper + { + [DllImport("powrprof.dll", EntryPoint = "PowerSetActiveOverlayScheme")] + internal static extern uint PowerSetActiveOverlayScheme(Guid OverlaySchemeGuid); + + [DllImport("powrprof.dll", EntryPoint = "PowerGetActualOverlayScheme")] + internal static extern uint PowerGetActualOverlayScheme(out Guid ActualOverlayGuid); + + [DllImport("powrprof.dll", EntryPoint = "PowerGetEffectiveOverlayScheme")] + internal static extern uint PowerGetEffectiveOverlayScheme(out Guid EffectiveOverlayGuid); + } +} diff --git a/src/Helpers/Timer.cs b/src/Helpers/Timer.cs new file mode 100644 index 0000000..903929d --- /dev/null +++ b/src/Helpers/Timer.cs @@ -0,0 +1,47 @@ +using System.Threading; + +namespace BatteryTracker.Helpers; + +class Timer +{ + public Action? Action { get; set; } + + System.Threading.Timer? _timer; + + int _interval; // in milliseconds + bool _continue = true; + + public async void StartTimer(int interval) + { + _continue = true; + _interval = interval; + if (_timer != null) + { + await _timer.DisposeAsync(); + } + _timer = new System.Threading.Timer(Tick, null, interval, Timeout.Infinite); + } + + public void StopTimer() => _continue = false; + + void Tick(object state) + { + try + { + Action?.Invoke(); + } + finally + { + if (_continue) + { + _timer?.Change(_interval, Timeout.Infinite); + } + } + } + + ~Timer() + { + _timer?.Dispose(); + } +} + diff --git a/src/MainWindow.xaml b/src/MainWindow.xaml index e1e5e65..e5688a4 100644 --- a/src/MainWindow.xaml +++ b/src/MainWindow.xaml @@ -15,8 +15,10 @@ + ContextMenuMode="PopupMenu" + LeftClickCommand="{StaticResource DisplayBatteryInfoCommand}" + NoLeftClickDelay="True" ToolTipText="Battery Tracker" + Visibility="Visible"> + xmlns:tb="using:H.NotifyIcon"> - + x:Key="OpenSettingsCommand" x:Uid="OpenSettingsCommand" + Description="Open settings window" /> - + x:Key="ExitApplicationCommand" x:Uid="ExitApplicationCommand" + Description="Exit" /> + \ No newline at end of file diff --git a/src/Services/PowerService.cs b/src/Services/PowerService.cs new file mode 100644 index 0000000..db06267 --- /dev/null +++ b/src/Services/PowerService.cs @@ -0,0 +1,37 @@ +using BatteryTracker.Contracts.Services; +using BatteryTracker.Helpers; +using BatteryTracker.Models; +using Microsoft.Extensions.Logging; + +namespace BatteryTracker.Services; + +public sealed class PowerService : IPowerService +{ + readonly ILogger _logger; + + public PowerService(ILogger logger) + { + _logger = logger; + } + + public PowerMode GetPowerMode() + { + uint result = PowerHelper.PowerGetActualOverlayScheme(out Guid powerModeId); + if (result != 0) + { + _logger.LogError("Failed to fetch current power mode"); + throw new RuntimeException("Failed to fetch current power mode"); + } + return PowerMode.GuidToPowerMode(powerModeId); + } + + public void SetPowerMode(PowerMode powerMode) + { + uint result = PowerHelper.PowerSetActiveOverlayScheme(powerMode.Guid); + if (result != 0) + { + _logger.LogError("Failed to set current power mode: {powerModeId}", powerMode); + throw new RuntimeException($"Failed to set current power mode: {powerMode}"); + } + } +} diff --git a/src/Services/SettingsService.cs b/src/Services/SettingsService.cs index b0fa8e4..60e2bdb 100644 --- a/src/Services/SettingsService.cs +++ b/src/Services/SettingsService.cs @@ -146,6 +146,7 @@ public SettingsService(ISettingsStorageService settingsStorageService, ILogger(SettingVersionSettingsKey, null); diff --git a/src/Styles/Button.xaml b/src/Styles/Button.xaml index 2d736cc..e0e888b 100644 --- a/src/Styles/Button.xaml +++ b/src/Styles/Button.xaml @@ -1,6 +1,4 @@ - + @@ -119,6 +117,12 @@ Margin="{TemplateBinding Padding}" Background="{TemplateBinding Background}" CornerRadius="4"> + @@ -166,12 +170,6 @@ - @@ -179,9 +177,10 @@ - - diff --git a/src/Styles/Thickness.xaml b/src/Styles/Thickness.xaml index 5e46377..0564c19 100644 --- a/src/Styles/Thickness.xaml +++ b/src/Styles/Thickness.xaml @@ -1,6 +1,4 @@ - + 0,36,0,0 0,36,0,36 @@ -33,5 +31,5 @@ 36,24,36,0 -12,4,0,0 - + diff --git a/src/ViewModels/BatteryInfoViewModel.cs b/src/ViewModels/BatteryInfoViewModel.cs new file mode 100644 index 0000000..1ff4a16 --- /dev/null +++ b/src/ViewModels/BatteryInfoViewModel.cs @@ -0,0 +1,152 @@ +using BatteryTracker.Contracts.Services; +using BatteryTracker.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using Microsoft.Windows.System.Power; +using Windows.Devices.Power; +using BatteryTracker.Helpers; +using CommunityToolkit.WinUI; +using Microsoft.UI.Dispatching; + +namespace BatteryTracker.ViewModels +{ + public sealed class BatteryInfoViewModel : ObservableRecipient + { + #region Private fields + + readonly DispatcherQueue _dispatcher = DispatcherQueue.GetForCurrentThread(); + + BatteryInfo _batteryInfo; + PowerMode _powerMode; + int _refreshInterval = 5000; // todo: allow customizing refresh interval in settings + + readonly ILogger _logger; + readonly IPowerService _powerService; + + readonly Timer _timer; + + #endregion + + #region Properties + + public int ChargePercent + { + get => _batteryInfo.ChargePercent; + set => SetProperty(ref _batteryInfo.ChargePercent, value); + } + + public BatteryStatus BatteryStatus + { + get => _batteryInfo.BatteryStatus; + private set => SetProperty(ref _batteryInfo.BatteryStatus, value); + } + + public PowerSupplyStatus PowerSupplyStatus + { + get => _batteryInfo.PowerSupplyStatus; + set => SetProperty(ref _batteryInfo.PowerSupplyStatus, value); + } + + public int DesignedCapacity + { + get => _batteryInfo.DesignedCapacity; + private set => SetProperty(ref _batteryInfo.DesignedCapacity, value); + } + + public int MaxCapacity + { + get => _batteryInfo.MaxCapacity; + private set => SetProperty(ref _batteryInfo.MaxCapacity, value); + } + + public int RemainingCapacity + { + get => _batteryInfo.RemainingCapacity; + private set => SetProperty(ref _batteryInfo.RemainingCapacity, value); + } + + public int ChargingRate + { + get => _batteryInfo.ChargingRate; + private set => SetProperty(ref _batteryInfo.ChargingRate, value); + } + + public PowerMode PowerMode + { + get => _powerMode; + set + { + _powerService.SetPowerMode(value); + SetProperty(ref _powerMode, value); + } + } + + #endregion + + public BatteryInfoViewModel(ILogger logger, IPowerService powerService) + { + _logger = logger; + _powerService = powerService; + _powerMode = PowerMode.Balanced; + PowerMode = _powerService.GetPowerMode(); + + _timer = new Timer(); + _timer.Action += UpdateStatus; + } + + ~BatteryInfoViewModel() + { + _timer.Action -= UpdateStatus; + } + + public void StartUpdatingStatus() + { + UpdateStatus(); + _timer.StartTimer(_refreshInterval); + } + + public void StopUpdatingStatus() => _timer.StopTimer(); + + void UpdateStatus() + { + _dispatcher.TryEnqueue(() => + { + // Update battery info + ChargePercent = PowerManager.RemainingChargePercent; + BatteryStatus = PowerManager.BatteryStatus; + PowerSupplyStatus = PowerManager.PowerSupplyStatus; + + BatteryReport info = Battery.AggregateBattery.GetReport(); + if (info == null) + { + _logger.LogError("Failed to fetch battery report"); + } + else + { + DesignedCapacity = info.DesignCapacityInMilliwattHours!.Value; + MaxCapacity = info.FullChargeCapacityInMilliwattHours!.Value; + RemainingCapacity = info.RemainingCapacityInMilliwattHours!.Value; + ChargingRate = info.ChargeRateInMilliwatts!.Value; + } + }); + } + + #region Binding functions + + internal static string GetRemainingCapacityText(int capacity) + => $"{(double)capacity / 1000:0.##} Wh"; + + internal static string GetBatteryHealthText(int maxCapacity, int designedCapacity) + => $"{(double)maxCapacity * 100 / designedCapacity:0.#}% " + + $"({(double)maxCapacity / 1000:0.##} Wh / {(double)designedCapacity / 1000} Wh)"; + + internal static string GetChargingRateText(int rate, BatteryStatus status) + => $"{(double)rate / 1000:0.##} W ({status})"; + + // todo: localize this text + internal static string GetPowerModeText(PowerMode powerMode) + => $"Power Mode: {powerMode}"; + + #endregion + } +} diff --git a/src/ViewModels/SettingsViewModel.cs b/src/ViewModels/SettingsViewModel.cs index c6d5a67..84417aa 100644 --- a/src/ViewModels/SettingsViewModel.cs +++ b/src/ViewModels/SettingsViewModel.cs @@ -228,6 +228,7 @@ public SettingsViewModel(BatteryIcon icon, IThemeSelectorService themeSelectorSe }); _appLanguageId = _settingsService.Language.LanguageId; + _language = Languages[0]; // dummy line to disable compiler warning ReadSettingValues(); LanguageChanged = false; diff --git a/src/Views/BatteryIcon.cs b/src/Views/BatteryIcon.cs index 3e43978..f75f20a 100644 --- a/src/Views/BatteryIcon.cs +++ b/src/Views/BatteryIcon.cs @@ -109,6 +109,8 @@ private set #endregion + internal readonly BatteryInfoWindow BatteryInfoWindow; + #region Private fields private static readonly Brush White = new SolidColorBrush(Color.FromArgb(255, 255, 255, 255)); @@ -133,6 +135,7 @@ public BatteryIcon(IAppNotificationService notificationService, ILogger + + + + + + + 0.7 + 12 + 14 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/BatteryInfoPage.xaml.cs b/src/Views/BatteryInfoPage.xaml.cs new file mode 100644 index 0000000..f1c5da3 --- /dev/null +++ b/src/Views/BatteryInfoPage.xaml.cs @@ -0,0 +1,15 @@ +using BatteryTracker.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace BatteryTracker.Views; + +public sealed partial class BatteryInfoPage : Page +{ + public BatteryInfoViewModel ViewModel { get; } + + public BatteryInfoPage() + { + ViewModel = App.GetService(); + InitializeComponent(); + } +} diff --git a/src/Views/BatteryInfoWindow.xaml b/src/Views/BatteryInfoWindow.xaml new file mode 100644 index 0000000..6f9d9bc --- /dev/null +++ b/src/Views/BatteryInfoWindow.xaml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/Views/BatteryInfoWindow.xaml.cs b/src/Views/BatteryInfoWindow.xaml.cs new file mode 100644 index 0000000..e036f34 --- /dev/null +++ b/src/Views/BatteryInfoWindow.xaml.cs @@ -0,0 +1,48 @@ +using WinUIEx; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace BatteryTracker.Views +{ + /// + /// An empty window that can be used on its own or navigated to within a Frame. + /// + public sealed partial class BatteryInfoWindow : WindowEx + { + public BatteryInfoWindow() + { + InitializeComponent(); + Activated += OnActivated; + Closed += BatteryInfoWindow_Closed; + } + + void BatteryInfoWindow_Closed(object sender, WindowEventArgs args) + { + args.Handled = true; + this.Hide(); + } + + void OnActivated(object sender, WindowActivatedEventArgs args) + { + switch (args.WindowActivationState) + { + // Hide the window on losing focus + case WindowActivationState.Deactivated: + BatteryInfoPage.ViewModel.StopUpdatingStatus(); + this.Hide(); + break; + // Fetch the latest battery info and display the window + case WindowActivationState.CodeActivated: + case WindowActivationState.PointerActivated: + BatteryInfoPage.ViewModel.StartUpdatingStatus(); + BringToFront(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(args), "Invalid WindowActivationState"); + } + } + + + } +} diff --git a/src/Views/SettingsPage.xaml.cs b/src/Views/SettingsPage.xaml.cs index 13b24b5..74c4e41 100644 --- a/src/Views/SettingsPage.xaml.cs +++ b/src/Views/SettingsPage.xaml.cs @@ -1,5 +1,4 @@ -using BatteryTracker.Helpers; -using BatteryTracker.ViewModels; +using BatteryTracker.ViewModels; using Microsoft.UI.Xaml.Controls; namespace BatteryTracker.Views;