[Phase L2-8] DockBar 위젯 확장 — 날씨·일정·배터리 추가

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 10:50:57 +09:00
parent f557f53449
commit fc881124b9
4 changed files with 407 additions and 101 deletions

View File

@@ -0,0 +1,63 @@
using System.Net.Http;
namespace AxCopilot.Services;
/// <summary>
/// 날씨 위젯용 데이터 서비스.
/// 사외 모드에서만 외부 API(wttr.in)를 호출하며 30분간 결과를 캐시합니다.
/// 사내 모드(InternalModeEnabled=true)에서는 "--"를 즉시 반환합니다.
/// </summary>
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);
/// <summary>현재 캐시된 날씨 텍스트 (없으면 "--")</summary>
public static string CachedText => _cached ?? "--";
/// <summary>
/// 날씨 정보를 비동기로 갱신합니다.
/// 사내 모드이거나 캐시 유효 기간 내이면 즉시 반환합니다.
/// </summary>
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;
}
}
/// <summary>캐시를 강제 초기화하여 다음 호출 시 재조회합니다.</summary>
public static void Invalidate()
{
_cached = null;
_cacheTime = DateTime.MinValue;
}
}

View File

@@ -57,6 +57,51 @@ public partial class LauncherViewModel
public bool Widget_McpOnline => ServerStatusService.Instance.McpOnline; public bool Widget_McpOnline => ServerStatusService.Instance.McpOnline;
public string Widget_McpName => ServerStatusService.Instance.McpName; public string Widget_McpName => ServerStatusService.Instance.McpName;
// ─── 날씨 ─────────────────────────────────────────────────────────────────
private string _widget_WeatherText = "--";
/// <summary>날씨 위젯 텍스트 예: "⛅ 18°C" (사외 모드) / "--" (사내 모드)</summary>
public string Widget_WeatherText
{
get => _widget_WeatherText;
internal set { _widget_WeatherText = value; OnPropertyChanged(); }
}
// ─── 날짜/일정 ────────────────────────────────────────────────────────────
/// <summary>날짜 위젯 텍스트 예: "4/4 (화)"</summary>
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;
/// <summary>배터리 퍼센트 및 충전 표시. 예: "87%" / "45% ⚡"</summary>
public string Widget_BatteryText
{
get => _widget_BatteryText;
internal set { _widget_BatteryText = value; OnPropertyChanged(); }
}
/// <summary>배터리 잔량별 Segoe MDL2 아이콘 코드포인트</summary>
public string Widget_BatteryIcon
{
get => _widget_BatteryIcon;
internal set { _widget_BatteryIcon = value; OnPropertyChanged(); }
}
/// <summary>배터리 위젯 표시 여부 (데스크톱/배터리 없음이면 false)</summary>
public bool Widget_BatteryVisible
{
get => _widget_BatteryVisible;
internal set { _widget_BatteryVisible = value; OnPropertyChanged(); }
}
// ─── 갱신 메서드 ────────────────────────────────────────────────────────── // ─── 갱신 메서드 ──────────────────────────────────────────────────────────
/// <summary>1초마다 LauncherWindow.Widgets.cs에서 호출 — UI 바인딩 갱신.</summary> /// <summary>1초마다 LauncherWindow.Widgets.cs에서 호출 — UI 바인딩 갱신.</summary>
@@ -75,6 +120,9 @@ public partial class LauncherViewModel
OnPropertyChanged(nameof(Widget_LlmOnline)); OnPropertyChanged(nameof(Widget_LlmOnline));
OnPropertyChanged(nameof(Widget_McpOnline)); OnPropertyChanged(nameof(Widget_McpOnline));
OnPropertyChanged(nameof(Widget_McpName)); OnPropertyChanged(nameof(Widget_McpName));
OnPropertyChanged(nameof(Widget_CalText));
// WeatherText / BatteryText / BatteryIcon / BatteryVisible 는
// LauncherWindow.Widgets.cs에서 직접 setter 호출로 갱신
} }
private int _widgetRefreshTick; private int _widgetRefreshTick;

View File

@@ -45,6 +45,9 @@ public partial class LauncherWindow
// 메모 건수 즉시 갱신 (최초 1회) // 메모 건수 즉시 갱신 (최초 1회)
_vm.UpdateWidgets(); _vm.UpdateWidgets();
UpdateServerDots(); UpdateServerDots();
UpdateBatteryWidget();
// 날씨: 사외 모드에서만 비동기 갱신
_ = RefreshWeatherAsync();
// 1초 타이머 // 1초 타이머
if (_widgetTimer == null) if (_widgetTimer == null)
@@ -57,6 +60,14 @@ public partial class LauncherWindow
{ {
_vm.UpdateWidgets(); _vm.UpdateWidgets();
UpdateServerDots(); UpdateServerDots();
// 배터리: 30초마다 갱신
if (_vm.Widget_PerfText.Length > 0 && _widgetBatteryTick++ % 30 == 0)
UpdateBatteryWidget();
// 날씨: 2분마다 서비스 쪽 캐시를 체크 (실제 API 호출은 30분마다)
if (_widgetWeatherTick++ % 120 == 0)
_ = RefreshWeatherAsync();
}; };
} }
_widgetTimer.Start(); _widgetTimer.Start();
@@ -65,6 +76,9 @@ public partial class LauncherWindow
UpdatePomoWidgetStyle(); UpdatePomoWidgetStyle();
} }
private int _widgetBatteryTick;
private int _widgetWeatherTick;
/// <summary>런처가 숨겨질 때 타이머 중지 (뽀모도로는 계속 실행).</summary> /// <summary>런처가 숨겨질 때 타이머 중지 (뽀모도로는 계속 실행).</summary>
internal void StopWidgetUpdates() internal void StopWidgetUpdates()
{ {
@@ -140,4 +154,112 @@ public partial class LauncherWindow
_vm.InputText = "port"; _vm.InputText = "port";
InputBox?.Focus(); 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;
});
}
} }

View File

@@ -790,116 +790,189 @@
Margin="0,0,0,8" Margin="0,0,0,8"
Opacity="0.7"/> Opacity="0.7"/>
<!-- ─── Phase L3-9: 미니 위젯 바 ─── --> <!-- ─── 위젯 바 (Row A: 기존 4개 / Row B: 날씨·일정·배터리) ─── -->
<Border x:Name="WidgetBar" <Border x:Name="WidgetBar"
Grid.Row="6" Grid.Row="6"
BorderBrush="{DynamicResource SeparatorColor}" BorderBrush="{DynamicResource SeparatorColor}"
BorderThickness="0,1,0,0" BorderThickness="0,1,0,0"
Padding="10,7,10,9"> Padding="10,7,10,9">
<Grid> <StackPanel>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- A: 시스템 모니터 --> <!-- ── Row A: 시스템·포모·메모·서버 ── -->
<Border x:Name="WgtPerf" Grid.Column="0" <Grid>
CornerRadius="5" Padding="8,5" <Grid.ColumnDefinitions>
Background="#0D60A5FA" <ColumnDefinition Width="*"/>
Cursor="Hand" <ColumnDefinition Width="6"/>
MouseLeftButtonUp="WgtPerf_Click"> <ColumnDefinition Width="Auto"/>
<StackPanel Orientation="Horizontal"> <ColumnDefinition Width="6"/>
<TextBlock Text="&#xE950;" <ColumnDefinition Width="Auto"/>
FontFamily="Segoe MDL2 Assets" FontSize="10" <ColumnDefinition Width="6"/>
Foreground="#60A5FA" <ColumnDefinition Width="Auto"/>
VerticalAlignment="Center" Margin="0,0,5,0"/> </Grid.ColumnDefinitions>
<TextBlock Text="{Binding Widget_PerfText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- B: 뽀모도로 타이머 --> <!-- A: 시스템 모니터 -->
<Border x:Name="WgtPomo" Grid.Column="2" <Border x:Name="WgtPerf" Grid.Column="0"
CornerRadius="5" Padding="8,5" CornerRadius="5" Padding="8,5"
Background="#0DF59E0B" Background="#0D60A5FA"
Cursor="Hand" Cursor="Hand"
MouseLeftButtonUp="WgtPomo_Click"> MouseLeftButtonUp="WgtPerf_Click">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE916;" <TextBlock Text="&#xE950;"
FontFamily="Segoe MDL2 Assets" FontSize="10" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#F59E0B" Foreground="#60A5FA"
VerticalAlignment="Center" Margin="0,0,5,0"/> VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock x:Name="WgtPomoText" <TextBlock Text="{Binding Widget_PerfText}"
Text="{Binding Widget_PomoText}" FontSize="10"
FontSize="10" Foreground="{DynamicResource SecondaryText}"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"/>
VerticalAlignment="Center"/> </StackPanel>
</StackPanel> </Border>
</Border>
<!-- C: 빠른 메모 --> <!-- B: 뽀모도로 타이머 -->
<Border x:Name="WgtNote" Grid.Column="4" <Border x:Name="WgtPomo" Grid.Column="2"
CornerRadius="5" Padding="8,5" CornerRadius="5" Padding="8,5"
Background="#0D8B5CF6" Background="#0DF59E0B"
Cursor="Hand" Cursor="Hand"
MouseLeftButtonUp="WgtNote_Click"> MouseLeftButtonUp="WgtPomo_Click">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE70B;" <TextBlock Text="&#xE916;"
FontFamily="Segoe MDL2 Assets" FontSize="10" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#8B5CF6" Foreground="#F59E0B"
VerticalAlignment="Center" Margin="0,0,5,0"/> VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_NoteText}" <TextBlock x:Name="WgtPomoText"
FontSize="10" Text="{Binding Widget_PomoText}"
Foreground="{DynamicResource SecondaryText}" FontSize="10"
VerticalAlignment="Center"/> Foreground="{DynamicResource SecondaryText}"
</StackPanel> VerticalAlignment="Center"/>
</Border> </StackPanel>
</Border>
<!-- D: 서버 상태 --> <!-- C: 빠른 메모 -->
<Border x:Name="WgtServer" Grid.Column="6" <Border x:Name="WgtNote" Grid.Column="4"
CornerRadius="5" Padding="8,5" CornerRadius="5" Padding="8,5"
Background="#0D10B981" Background="#0D8B5CF6"
Cursor="Hand" Cursor="Hand"
MouseLeftButtonUp="WgtServer_Click"> MouseLeftButtonUp="WgtNote_Click">
<StackPanel Orientation="Horizontal" x:Name="WgtServerContent"> <StackPanel Orientation="Horizontal">
<!-- Ollama 상태 --> <TextBlock Text="&#xE70B;"
<Ellipse x:Name="OllamaStatusDot" FontFamily="Segoe MDL2 Assets" FontSize="10"
Width="6" Height="6" Foreground="#8B5CF6"
Fill="#9E9E9E" VerticalAlignment="Center" Margin="0,0,5,0"/>
VerticalAlignment="Center" Margin="0,0,3,0"/> <TextBlock Text="{Binding Widget_NoteText}"
<TextBlock Text="Ollama" FontSize="10"
FontSize="10" Foreground="{DynamicResource SecondaryText}"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"/>
VerticalAlignment="Center" Margin="0,0,8,0"/> </StackPanel>
<!-- LLM API 상태 --> </Border>
<Ellipse x:Name="LlmStatusDot"
Width="6" Height="6" <!-- D: 서버 상태 -->
Fill="#9E9E9E" <Border x:Name="WgtServer" Grid.Column="6"
VerticalAlignment="Center" Margin="0,0,3,0"/> CornerRadius="5" Padding="8,5"
<TextBlock Text="API" Background="#0D10B981"
FontSize="10" Cursor="Hand"
Foreground="{DynamicResource SecondaryText}" MouseLeftButtonUp="WgtServer_Click">
VerticalAlignment="Center" Margin="0,0,8,0"/> <StackPanel Orientation="Horizontal" x:Name="WgtServerContent">
<!-- MCP 상태 --> <!-- Ollama 상태 -->
<Ellipse x:Name="McpStatusDot" <Ellipse x:Name="OllamaStatusDot"
Width="6" Height="6" Width="6" Height="6"
Fill="#9E9E9E" Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/> VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock x:Name="McpNameText" <TextBlock Text="Ollama"
Text="{Binding Widget_McpName}" FontSize="10"
FontSize="10" Foreground="{DynamicResource SecondaryText}"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center" Margin="0,0,8,0"/>
VerticalAlignment="Center"/> <!-- LLM API 상태 -->
</StackPanel> <Ellipse x:Name="LlmStatusDot"
</Border> Width="6" Height="6"
</Grid> Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock Text="API"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<!-- MCP 상태 -->
<Ellipse x:Name="McpStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock x:Name="McpNameText"
Text="{Binding Widget_McpName}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
<!-- ── Row B: 날씨·일정·배터리 ── -->
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- E: 날씨 -->
<Border x:Name="WgtWeather" Grid.Column="0"
CornerRadius="5" Padding="8,5"
Background="#0D3B82F6"
Cursor="Hand"
MouseLeftButtonUp="WgtWeather_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE708;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#60A5FA"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_WeatherText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="100"/>
</StackPanel>
</Border>
<!-- F: 날짜/일정 -->
<Border x:Name="WgtCal" Grid.Column="2"
CornerRadius="5" Padding="8,5"
Background="#0DEC4899"
Cursor="Hand"
MouseLeftButtonUp="WgtCal_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8BF;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#EC4899"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_CalText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- G: 배터리 -->
<Border x:Name="WgtBattery" Grid.Column="4"
CornerRadius="5" Padding="8,5"
Background="#0D10B981"
Cursor="Hand"
MouseLeftButtonUp="WgtBattery_Click"
Visibility="{Binding Widget_BatteryVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Widget_BatteryIcon}"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#10B981"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_BatteryText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border> </Border>
<!-- ─── 토스트 오버레이 ─── --> <!-- ─── 토스트 오버레이 ─── -->