From fc881124b9f6594355762925078d248fff78f616 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 10:50:57 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L2-8]=20DockBar=20=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=20=E2=80=94=20=EB=82=A0=EC=94=A8=C2=B7?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=C2=B7=EB=B0=B0=ED=84=B0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services/WeatherWidgetService.cs (신규 58줄): - wttr.in API 호출, 30분 캐시 - 사내 모드(InternalModeEnabled=true)에서는 "--" 즉시 반환 - Invalidate()로 강제 캐시 초기화 지원 ViewModels/LauncherViewModel.Widgets.cs: - Widget_WeatherText (setter: 코드비하인드에서 직접 갱신) - Widget_CalText: DateTime.Now → "M/d (ddd)" 형식 (ko-KR) - Widget_BatteryText / Widget_BatteryIcon / Widget_BatteryVisible Views/LauncherWindow.Widgets.cs: - StartWidgetUpdates(): 배터리 즉시 갱신 + 날씨 비동기 갱신 호출 - 1초 타이머: 배터리 30초마다, 날씨 캐시체크 2분마다 - UpdateBatteryWidget(): PowerStatus 읽기, 잔량별 MDL2 아이콘 선택 - RefreshWeatherAsync(): WeatherWidgetService 호출 → VM 프로퍼티 갱신 - WgtWeather_Click: 사외 모드=wttr.in 열기, 사내 모드=캐시 초기화 - WgtCal_Click: ms-clock: 또는 outlookcal: 열기 - WgtBattery_Click: ms-settings:powersleep 열기 Views/LauncherWindow.xaml: - WidgetBar 내부: Grid → StackPanel + 2행 구조로 변환 - Row A: 기존 4개 위젯 (시스템·포모·메모·서버, 변경 없음) - Row B: E(날씨 파랑) · F(일정 핑크) · G(배터리 녹색) 신규 추가 - 배터리: BoolToVisibilityConverter로 데스크톱에서 자동 숨김 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- .../Services/WeatherWidgetService.cs | 63 ++++ .../ViewModels/LauncherViewModel.Widgets.cs | 48 +++ src/AxCopilot/Views/LauncherWindow.Widgets.cs | 122 ++++++++ src/AxCopilot/Views/LauncherWindow.xaml | 275 +++++++++++------- 4 files changed, 407 insertions(+), 101 deletions(-) create mode 100644 src/AxCopilot/Services/WeatherWidgetService.cs diff --git a/src/AxCopilot/Services/WeatherWidgetService.cs b/src/AxCopilot/Services/WeatherWidgetService.cs new file mode 100644 index 0000000..3de0d35 --- /dev/null +++ b/src/AxCopilot/Services/WeatherWidgetService.cs @@ -0,0 +1,63 @@ +using System.Net.Http; + +namespace AxCopilot.Services; + +/// +/// 날씨 위젯용 데이터 서비스. +/// 사외 모드에서만 외부 API(wttr.in)를 호출하며 30분간 결과를 캐시합니다. +/// 사내 모드(InternalModeEnabled=true)에서는 "--"를 즉시 반환합니다. +/// +internal static class WeatherWidgetService +{ + private static string? _cached; + private static DateTime _cacheTime = DateTime.MinValue; + private static bool _fetching; + private static readonly TimeSpan _ttl = TimeSpan.FromMinutes(30); + + /// 현재 캐시된 날씨 텍스트 (없으면 "--") + public static string CachedText => _cached ?? "--"; + + /// + /// 날씨 정보를 비동기로 갱신합니다. + /// 사내 모드이거나 캐시 유효 기간 내이면 즉시 반환합니다. + /// + public static async Task RefreshAsync(bool internalMode) + { + if (internalMode) + { + _cached = "--"; + return; + } + + // 캐시 유효하면 재호출 불필요 + if (_cached != null && DateTime.Now - _cacheTime < _ttl) return; + if (_fetching) return; + + _fetching = true; + try + { + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(6); + // wttr.in: %c = 날씨 조건 이모지, %t = 기온 + var raw = await client.GetStringAsync("https://wttr.in/?format=%c+%t"); + _cached = raw.Trim().Replace("+", " "); + _cacheTime = DateTime.Now; + } + catch (Exception ex) + { + LogService.Warn($"날씨 위젯 갱신 실패: {ex.Message}"); + _cached ??= "--"; + } + finally + { + _fetching = false; + } + } + + /// 캐시를 강제 초기화하여 다음 호출 시 재조회합니다. + public static void Invalidate() + { + _cached = null; + _cacheTime = DateTime.MinValue; + } +} diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs b/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs index 75e610a..befc388 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs @@ -57,6 +57,51 @@ public partial class LauncherViewModel public bool Widget_McpOnline => ServerStatusService.Instance.McpOnline; public string Widget_McpName => ServerStatusService.Instance.McpName; + // ─── 날씨 ───────────────────────────────────────────────────────────────── + + private string _widget_WeatherText = "--"; + + /// 날씨 위젯 텍스트 예: "⛅ 18°C" (사외 모드) / "--" (사내 모드) + public string Widget_WeatherText + { + get => _widget_WeatherText; + internal set { _widget_WeatherText = value; OnPropertyChanged(); } + } + + // ─── 날짜/일정 ──────────────────────────────────────────────────────────── + + /// 날짜 위젯 텍스트 예: "4/4 (화)" + public string Widget_CalText => + DateTime.Now.ToString("M/d (ddd)", + System.Globalization.CultureInfo.GetCultureInfo("ko-KR")); + + // ─── 배터리 ─────────────────────────────────────────────────────────────── + + private string _widget_BatteryText = "--"; + private string _widget_BatteryIcon = "\uE83F"; + private bool _widget_BatteryVisible = false; + + /// 배터리 퍼센트 및 충전 표시. 예: "87%" / "45% ⚡" + public string Widget_BatteryText + { + get => _widget_BatteryText; + internal set { _widget_BatteryText = value; OnPropertyChanged(); } + } + + /// 배터리 잔량별 Segoe MDL2 아이콘 코드포인트 + public string Widget_BatteryIcon + { + get => _widget_BatteryIcon; + internal set { _widget_BatteryIcon = value; OnPropertyChanged(); } + } + + /// 배터리 위젯 표시 여부 (데스크톱/배터리 없음이면 false) + public bool Widget_BatteryVisible + { + get => _widget_BatteryVisible; + internal set { _widget_BatteryVisible = value; OnPropertyChanged(); } + } + // ─── 갱신 메서드 ────────────────────────────────────────────────────────── /// 1초마다 LauncherWindow.Widgets.cs에서 호출 — UI 바인딩 갱신. @@ -75,6 +120,9 @@ public partial class LauncherViewModel OnPropertyChanged(nameof(Widget_LlmOnline)); OnPropertyChanged(nameof(Widget_McpOnline)); OnPropertyChanged(nameof(Widget_McpName)); + OnPropertyChanged(nameof(Widget_CalText)); + // WeatherText / BatteryText / BatteryIcon / BatteryVisible 는 + // LauncherWindow.Widgets.cs에서 직접 setter 호출로 갱신 } private int _widgetRefreshTick; diff --git a/src/AxCopilot/Views/LauncherWindow.Widgets.cs b/src/AxCopilot/Views/LauncherWindow.Widgets.cs index 9d75e30..52d01c5 100644 --- a/src/AxCopilot/Views/LauncherWindow.Widgets.cs +++ b/src/AxCopilot/Views/LauncherWindow.Widgets.cs @@ -45,6 +45,9 @@ public partial class LauncherWindow // 메모 건수 즉시 갱신 (최초 1회) _vm.UpdateWidgets(); UpdateServerDots(); + UpdateBatteryWidget(); + // 날씨: 사외 모드에서만 비동기 갱신 + _ = RefreshWeatherAsync(); // 1초 타이머 if (_widgetTimer == null) @@ -57,6 +60,14 @@ public partial class LauncherWindow { _vm.UpdateWidgets(); UpdateServerDots(); + + // 배터리: 30초마다 갱신 + if (_vm.Widget_PerfText.Length > 0 && _widgetBatteryTick++ % 30 == 0) + UpdateBatteryWidget(); + + // 날씨: 2분마다 서비스 쪽 캐시를 체크 (실제 API 호출은 30분마다) + if (_widgetWeatherTick++ % 120 == 0) + _ = RefreshWeatherAsync(); }; } _widgetTimer.Start(); @@ -65,6 +76,9 @@ public partial class LauncherWindow UpdatePomoWidgetStyle(); } + private int _widgetBatteryTick; + private int _widgetWeatherTick; + /// 런처가 숨겨질 때 타이머 중지 (뽀모도로는 계속 실행). internal void StopWidgetUpdates() { @@ -140,4 +154,112 @@ public partial class LauncherWindow _vm.InputText = "port"; InputBox?.Focus(); } + + private void WgtWeather_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + var internalMode = CurrentApp?.SettingsService?.Settings.InternalModeEnabled ?? true; + if (!internalMode) + { + // 사외 모드: 브라우저에서 날씨 페이지 열기 + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + "https://wttr.in") { UseShellExecute = true }); + } + catch (Exception ex) + { + LogService.Warn($"날씨 링크 열기 실패: {ex.Message}"); + } + } + else + { + // 사내 모드: 날씨 캐시 무효화 후 재시도 (사외 모드 전환 시 즉시 갱신되도록) + WeatherWidgetService.Invalidate(); + _ = RefreshWeatherAsync(); + } + } + + private void WgtCal_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + // Windows 달력 앱 열기 + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + "ms-clock:") { UseShellExecute = true }); + } + catch + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + "outlookcal:") { UseShellExecute = true }); + } + catch (Exception ex) + { + LogService.Warn($"달력 앱 열기 실패: {ex.Message}"); + } + } + } + + private void WgtBattery_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + // 전원 및 절전 설정 열기 + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + "ms-settings:powersleep") { UseShellExecute = true }); + } + catch (Exception ex) + { + LogService.Warn($"전원 설정 열기 실패: {ex.Message}"); + } + } + + // ─── 배터리 상태 갱신 ────────────────────────────────────────────────────── + + private void UpdateBatteryWidget() + { + try + { + var ps = System.Windows.Forms.SystemInformation.PowerStatus; + var pct = ps.BatteryLifePercent; // 0.0~1.0, unknown = 255f + + if (pct > 1.0f || pct < 0f) + { + // 배터리 없는 장치 (데스크톱 등) → 위젯 숨김 + _vm.Widget_BatteryVisible = false; + return; + } + + _vm.Widget_BatteryVisible = true; + var pctInt = (int)(pct * 100); + var charging = ps.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online; + + _vm.Widget_BatteryText = charging ? $"{pctInt}% ⚡" : $"{pctInt}%"; + _vm.Widget_BatteryIcon = charging ? "\uE83E" // BatteryCharging + : pctInt >= 85 ? "\uEBA7" // Battery 8 (Full) + : pctInt >= 70 ? "\uEBA5" // Battery 6 + : pctInt >= 50 ? "\uEBA3" // Battery 4 + : pctInt >= 25 ? "\uEBA1" // Battery 2 + : "\uEBA0"; // Battery 1 (Low) + } + catch (Exception ex) + { + LogService.Warn($"배터리 위젯 갱신 실패: {ex.Message}"); + _vm.Widget_BatteryVisible = false; + } + } + + // ─── 날씨 비동기 갱신 ───────────────────────────────────────────────────── + + private async Task RefreshWeatherAsync() + { + var internalMode = CurrentApp?.SettingsService?.Settings.InternalModeEnabled ?? true; + await WeatherWidgetService.RefreshAsync(internalMode); + // UI 스레드에서 바인딩 프로퍼티 갱신 + await Dispatcher.InvokeAsync(() => + { + _vm.Widget_WeatherText = WeatherWidgetService.CachedText; + }); + } } diff --git a/src/AxCopilot/Views/LauncherWindow.xaml b/src/AxCopilot/Views/LauncherWindow.xaml index 1b3ea24..69a5953 100644 --- a/src/AxCopilot/Views/LauncherWindow.xaml +++ b/src/AxCopilot/Views/LauncherWindow.xaml @@ -790,116 +790,189 @@ Margin="0,0,0,8" Opacity="0.7"/> - + - - - - - - - - - - + - - - - - - - + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +