Skip to content

Commit 20d57dd

Browse files
feat: Add dynamic upper bound for speed test gauge based on historical data (#628)
* docs: Add UI Kit Library principle and translate constitution to English - Add Article XIV: UI Kit Library Principle with mandatory UI component usage rules - Translate all remaining Chinese content in constitution.md to English - Update "Last Amended" date to 2026-02-11 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add implement-feature-with-checks skill for automated constitution compliance Create new Claude Code skill that automatically enforces project constitution principles during feature implementation. Key features: - Requirements clarity check before implementation starts - Integrates with brainstorming skill for unclear requirements - Enforces UI Kit Library usage with component verification - Mandates test coverage (Service ≥90%, Provider ≥85%, Overall ≥80%) - Automates testing execution (unit tests + screenshot tests) - Runs code formatting (dart format) and analysis (flutter analyze) - Validates architecture compliance (layer separation, error handling) - Provides decision trees and examples for common scenarios The skill automatically triggers on keywords like 實作, 創建, 新增, 開發, 修改, 重構, 修正, etc., ensuring consistent adherence to constitution without manual reminders. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add dynamic upper bound for speed test gauge based on historical data The speed test gauge now automatically adjusts its upper limit based on historical test results, providing better visualization across different network speed ranges. Changes: - Add helper methods to calculate maximum historical speed and round to nearest hundred - Generate appropriate markers dynamically based on the upper bound - Update meterView and _startButton to use dynamic markers instead of fixed 100 Mbps limit Behavior: - No history: defaults to 100 Mbps (maintains backward compatibility) - With history: uses maximum speed from history, rounded up to nearest hundred (e.g., 345 Mbps → 400 Mbps upper bound) - Markers are distributed appropriately for different speed ranges (100-200, 200-500, 500-1000, 1000+ Mbps) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Ensure gauge markers stay sorted and within upper bound (#624) Fix marker generation bug in _generateMarkers for 500-1000 Mbps range. Previously, when upperBound was less than 750 (e.g., 600 or 700), the method unconditionally added 750 marker, resulting in unsorted list that exceeded the upper bound. Changes: - Add conditional check before adding 750 marker (only if upperBound >= 750) - Ensure markers list maintains ascending order - Ensure all markers stay within upperBound Before: upperBound=600 → [0, 100, 200, 300, 400, 500, 750, 600] (unsorted) After: upperBound=600 → [0, 100, 200, 300, 400, 500, 600] (sorted) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Extract gauge calculation logic to SpeedTestGaugeUtils Extract the 4 gauge calculation methods from SpeedTestWidget into a reusable SpeedTestGaugeUtils class with comprehensive unit tests. Changes: - Create SpeedTestGaugeUtils class with 4 static methods: - calculateMaxHistoricalSpeed(): Find max speed from history - roundUpToHundred(): Round up to nearest hundred - calculateGaugeUpperBound(): Calculate dynamic upper bound - generateMarkers(): Generate appropriate markers for different ranges - Add 39 comprehensive unit tests covering all edge cases - Refactor SpeedTestWidget to use the new utils class - Remove 4 private methods from SpeedTestWidget (130 lines) Benefits: - Improved testability: 100% test coverage for calculation logic - Better code organization: Follows project pattern (feature/utils/) - Reusability: Utils can be used in other contexts - Maintainability: Centralized logic with clear documentation Testing: - All 39 utils tests pass - All 23 existing health_check tests pass (regression) - Static analysis clean Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a98fc8f commit 20d57dd

3 files changed

Lines changed: 785 additions & 15 deletions

File tree

lib/page/health_check/shared_widgets/speed_test_widget.dart

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:privacy_gui/page/health_check/models/health_check_server.dart';
99
import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart';
1010
import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart';
1111
import 'package:privacy_gui/page/health_check/providers/health_check_state.dart';
12+
import 'package:privacy_gui/page/health_check/utils/speed_test_gauge_utils.dart';
1213
import 'package:privacy_gui/route/constants.dart';
1314
import 'package:privacy_gui/utils.dart';
1415
import 'package:ui_kit_library/ui_kit.dart';
@@ -223,6 +224,13 @@ class SpeedTestWidget extends ConsumerWidget {
223224
Widget meterView(
224225
BuildContext context, HealthCheckState state, WidgetRef ref) {
225226
final result = state.result ?? SpeedTestUIModel.empty();
227+
228+
// Calculate dynamic upper bound and markers based on historical data
229+
final upperBound = SpeedTestGaugeUtils.calculateGaugeUpperBound(state);
230+
final isSmallGauge = (meterSize ?? 220) < 130;
231+
final markers = SpeedTestGaugeUtils.generateMarkers(upperBound,
232+
isSmallGauge: isSmallGauge);
233+
226234
// Format the live meter value for display.
227235
final formattedLiveValue = NetworkUtils.formatBitsWithUnit(
228236
(state.meterValue * 1000).toInt(),
@@ -237,20 +245,7 @@ class SpeedTestWidget extends ConsumerWidget {
237245
child: AppGauge(
238246
size: meterSize ?? context.colWidth(3),
239247
value: meterValueMbps, // Value must be in Mbps for the meter scale
240-
// Reduce clutter: show minimal or no labels if meter is small
241-
markers: (meterSize ?? 220) < 130
242-
? const <double>[0, 100]
243-
: const <double>[
244-
0,
245-
1,
246-
5,
247-
10,
248-
20,
249-
30,
250-
50,
251-
75,
252-
100
253-
], // Markers are in Mbps
248+
markers: markers, // Dynamic markers based on historical data
254249
centerBuilder: (context, value) {
255250
// The content inside the meter (e.g., live speed).
256251
final isSmall = (meterSize ?? 220) < 130;
@@ -540,11 +535,18 @@ class SpeedTestWidget extends ConsumerWidget {
540535
/// Builds the initial "Go" button to start the test.
541536
Widget _startButton(BuildContext context, WidgetRef ref,
542537
{SpeedTestUIModel? lastResult}) {
538+
// Calculate dynamic upper bound and markers for idle state
539+
final healthCheckState = ref.watch(healthCheckProvider);
540+
final upperBound =
541+
SpeedTestGaugeUtils.calculateGaugeUpperBound(healthCheckState);
542+
final markers =
543+
SpeedTestGaugeUtils.generateMarkers(upperBound, isSmallGauge: true);
544+
543545
return Container(
544546
alignment: Alignment.center,
545547
child: AppGauge(
546548
size: meterSize ?? 220,
547-
markers: const <double>[0, 100], // Default markers for start button
549+
markers: markers, // Dynamic markers based on historical data
548550
// displayIndicatorValues: false, // assuming unsupported or default
549551
// indicatorPathStrokeWidth: 8,
550552
// markerRadius: 2,
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart';
2+
import 'package:privacy_gui/page/health_check/providers/health_check_state.dart';
3+
4+
/// Utility class for calculating speed test gauge parameters.
5+
///
6+
/// This class provides helper methods to determine dynamic gauge bounds and markers
7+
/// based on historical speed test results. All methods are static and pure functions.
8+
class SpeedTestGaugeUtils {
9+
// Private constructor to prevent instantiation
10+
SpeedTestGaugeUtils._();
11+
12+
/// Calculates the maximum speed (in Mbps) from historical speed test results.
13+
///
14+
/// This method examines both [state.historicalSpeedTests] and [state.latestSpeedTest]
15+
/// to find the highest download or upload speed recorded.
16+
///
17+
/// **Parameters:**
18+
/// - [state]: The current [HealthCheckState] containing speed test history
19+
///
20+
/// **Returns:**
21+
/// - The maximum speed in Mbps (converted from Kbps)
22+
/// - Returns 100.0 as default if no valid history exists
23+
///
24+
/// **Examples:**
25+
/// ```dart
26+
/// final maxSpeed = SpeedTestGaugeUtils.calculateMaxHistoricalSpeed(state);
27+
/// // Returns 150.5 if the fastest recorded speed was 150.5 Mbps
28+
/// ```
29+
static double calculateMaxHistoricalSpeed(HealthCheckState state) {
30+
final allTests = <SpeedTestUIModel>[];
31+
32+
// Include historical tests
33+
if (state.historicalSpeedTests.isNotEmpty) {
34+
allTests.addAll(state.historicalSpeedTests);
35+
}
36+
37+
// Include latest test if it's not already in historical
38+
if (state.latestSpeedTest != null &&
39+
!state.historicalSpeedTests.contains(state.latestSpeedTest)) {
40+
allTests.add(state.latestSpeedTest!);
41+
}
42+
43+
if (allTests.isEmpty) {
44+
return 100.0; // Default to 100 Mbps if no history
45+
}
46+
47+
double maxSpeed = 0.0;
48+
49+
for (final test in allTests) {
50+
// Download speed (convert Kbps to Mbps)
51+
if (test.downloadBandwidthKbps != null &&
52+
test.downloadBandwidthKbps! > 0) {
53+
final downloadMbps = test.downloadBandwidthKbps! / 1024.0;
54+
if (downloadMbps > maxSpeed) {
55+
maxSpeed = downloadMbps;
56+
}
57+
}
58+
59+
// Upload speed (convert Kbps to Mbps)
60+
if (test.uploadBandwidthKbps != null && test.uploadBandwidthKbps! > 0) {
61+
final uploadMbps = test.uploadBandwidthKbps! / 1024.0;
62+
if (uploadMbps > maxSpeed) {
63+
maxSpeed = uploadMbps;
64+
}
65+
}
66+
}
67+
68+
// If all values were null or zero, return default
69+
return maxSpeed > 0 ? maxSpeed : 100.0;
70+
}
71+
72+
/// Rounds up a speed value to the nearest hundred (ceiling).
73+
///
74+
/// This ensures gauge markers are at clean, readable intervals.
75+
/// The minimum return value is always 100.0.
76+
///
77+
/// **Parameters:**
78+
/// - [speed]: The speed value in Mbps to round up
79+
///
80+
/// **Returns:**
81+
/// - The rounded value, minimum 100.0
82+
///
83+
/// **Examples:**
84+
/// ```dart
85+
/// SpeedTestGaugeUtils.roundUpToHundred(89.0); // Returns 100.0
86+
/// SpeedTestGaugeUtils.roundUpToHundred(345.0); // Returns 400.0
87+
/// SpeedTestGaugeUtils.roundUpToHundred(1234.0); // Returns 1300.0
88+
/// SpeedTestGaugeUtils.roundUpToHundred(56.0); // Returns 100.0 (minimum)
89+
/// ```
90+
static double roundUpToHundred(double speed) {
91+
// Ensure minimum is 100
92+
if (speed < 100) {
93+
return 100.0;
94+
}
95+
96+
// Round up to nearest hundred
97+
return (speed / 100).ceil() * 100.0;
98+
}
99+
100+
/// Calculates the dynamic upper bound for the speed test gauge.
101+
///
102+
/// This combines [calculateMaxHistoricalSpeed] and [roundUpToHundred]
103+
/// to determine an appropriate maximum value for the gauge display.
104+
///
105+
/// **Parameters:**
106+
/// - [state]: The current [HealthCheckState] containing speed test history
107+
///
108+
/// **Returns:**
109+
/// - The calculated upper bound in Mbps (minimum 100.0, rounded to hundreds)
110+
///
111+
/// **Examples:**
112+
/// ```dart
113+
/// final upperBound = SpeedTestGaugeUtils.calculateGaugeUpperBound(state);
114+
/// // If max historical speed is 234 Mbps, returns 300.0
115+
/// ```
116+
static double calculateGaugeUpperBound(HealthCheckState state) {
117+
final maxHistorical = calculateMaxHistoricalSpeed(state);
118+
return roundUpToHundred(maxHistorical);
119+
}
120+
121+
/// Generates appropriate marker values for the speed gauge.
122+
///
123+
/// The markers are distributed to provide meaningful reference points based
124+
/// on the upper bound. Different ranges use different marker intervals:
125+
///
126+
/// - **100 Mbps**: `[0, 1, 5, 10, 20, 30, 50, 75, 100]`
127+
/// - **100-200 Mbps**: `[0, 10, 20, 30, 50, 75, 100, 150, upperBound]`
128+
/// - **200-500 Mbps**: `[0, 50, 100, 150, ..., upperBound]` (50 Mbps intervals)
129+
/// - **500-1000 Mbps**: `[0, 100, 200, ..., 500, 750, upperBound]` (100 Mbps intervals)
130+
/// - **1000+ Mbps**: `[0, 200, 400, ..., upperBound]` (200 Mbps intervals)
131+
///
132+
/// **Small gauge mode** (when [isSmallGauge] is true): Returns only `[0, upperBound]`
133+
///
134+
/// **Parameters:**
135+
/// - [upperBound]: The maximum value for the gauge in Mbps
136+
/// - [isSmallGauge]: If true, returns simplified markers for compact display (default: false)
137+
///
138+
/// **Returns:**
139+
/// - A sorted list of marker values in ascending order
140+
///
141+
/// **Examples:**
142+
/// ```dart
143+
/// SpeedTestGaugeUtils.generateMarkers(100.0);
144+
/// // Returns [0, 1, 5, 10, 20, 30, 50, 75, 100]
145+
///
146+
/// SpeedTestGaugeUtils.generateMarkers(300.0);
147+
/// // Returns [0, 50, 100, 150, 200, 250, 300]
148+
///
149+
/// SpeedTestGaugeUtils.generateMarkers(300.0, isSmallGauge: true);
150+
/// // Returns [0, 300]
151+
/// ```
152+
static List<double> generateMarkers(
153+
double upperBound, {
154+
bool isSmallGauge = false,
155+
}) {
156+
if (isSmallGauge) {
157+
return [0, upperBound];
158+
}
159+
160+
if (upperBound <= 100) {
161+
// Default case: 0-100 Mbps
162+
return const [0, 1, 5, 10, 20, 30, 50, 75, 100];
163+
} else if (upperBound <= 200) {
164+
// 100-200 Mbps range
165+
return [0, 10, 20, 30, 50, 75, 100, 150, upperBound];
166+
} else if (upperBound <= 500) {
167+
// 200-500 Mbps range
168+
// Generate markers at 50 Mbps intervals up to 300, then 100 Mbps intervals
169+
final markers = <double>[0];
170+
for (double i = 50; i <= 300; i += 50) {
171+
markers.add(i);
172+
}
173+
for (double i = 400; i < upperBound; i += 100) {
174+
markers.add(i);
175+
}
176+
markers.add(upperBound);
177+
return markers;
178+
} else if (upperBound <= 1000) {
179+
// 500-1000 Mbps range
180+
// Generate markers at 100 Mbps intervals, with an extra marker at 750 if applicable
181+
final markers = <double>[0];
182+
for (double i = 100; i <= 500; i += 100) {
183+
markers.add(i);
184+
}
185+
// Only add 750 if upperBound is >= 750 to maintain sorted order
186+
if (upperBound >= 750) {
187+
markers.add(750);
188+
}
189+
if (upperBound != 1000) {
190+
markers.add(upperBound);
191+
} else {
192+
markers.add(1000);
193+
}
194+
return markers;
195+
} else {
196+
// 1000+ Mbps range
197+
// Generate markers at 200 Mbps intervals
198+
final markers = <double>[0];
199+
for (double i = 200; i < upperBound; i += 200) {
200+
markers.add(i);
201+
}
202+
markers.add(upperBound);
203+
return markers;
204+
}
205+
}
206+
}

0 commit comments

Comments
 (0)