mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:flutter/material.dart';
2import 'package:flutter_riverpod/flutter_riverpod.dart';
3
4import '../application/performance_monitor_notifier.dart';
5
6/// A tab in the debug overlay that displays performance diagnostics.
7class PerformanceMetricsTab extends ConsumerWidget {
8 const PerformanceMetricsTab({super.key});
9
10 @override
11 Widget build(BuildContext context, WidgetRef ref) {
12 final state = ref.watch(performanceMonitorProvider);
13 final theme = Theme.of(context);
14
15 return ListView(
16 padding: const EdgeInsets.all(16),
17 children: [
18 _buildSection(
19 theme: theme,
20 title: 'Frame Rate',
21 child: _MetricCard(
22 label: 'Current FPS',
23 value: '${state.fps.toStringAsFixed(1)} fps',
24 color: _getFpsColor(state.fps, theme),
25 ),
26 ),
27 const SizedBox(height: 16),
28 _buildSection(
29 theme: theme,
30 title: 'Image Cache',
31 items: [
32 _InfoItem(label: 'Live Images', value: '${state.imageCacheLiveCount}'),
33 _InfoItem(label: 'Pending Images', value: '${state.imageCachePendingCount}'),
34 _InfoItem(label: 'Cache Size', value: _formatBytes(state.imageCacheByteCount)),
35 ],
36 ),
37 const SizedBox(height: 16),
38 _buildSection(
39 theme: theme,
40 title: 'Widget Rebuilds',
41 trailing: IconButton(
42 icon: const Icon(Icons.refresh, size: 20),
43 onPressed: () => ref.read(performanceMonitorProvider.notifier).resetRebuildCounts(),
44 tooltip: 'Reset Counts',
45 ),
46 child: state.rebuildCounts.isEmpty
47 ? _buildEmptyState(theme, 'No rebuilds tracked yet.')
48 : Column(
49 children: state.rebuildCounts.entries.map((e) {
50 return _InfoRow(
51 label: e.key,
52 value: '${e.value}',
53 showDivider: e.key != state.rebuildCounts.keys.last,
54 theme: theme,
55 );
56 }).toList(),
57 ),
58 ),
59 const SizedBox(height: 16),
60 _buildSection(
61 theme: theme,
62 title: 'Database Performance',
63 child: state.queryTimes.isEmpty
64 ? _buildEmptyState(theme, 'No queries logged.')
65 : Column(
66 children: [
67 _MetricCard(
68 label: 'Avg Query Time',
69 value: '${_calculateAvg(state.queryTimes).toStringAsFixed(2)} ms',
70 ),
71 const SizedBox(height: 8),
72 _MetricCard(
73 label: 'Max Query Time',
74 value: '${_calculateMax(state.queryTimes).toStringAsFixed(2)} ms',
75 ),
76 ],
77 ),
78 ),
79 ],
80 );
81 }
82
83 Widget _buildSection({
84 required ThemeData theme,
85 required String title,
86 Widget? child,
87 List<_InfoItem>? items,
88 Widget? trailing,
89 }) {
90 return Column(
91 crossAxisAlignment: CrossAxisAlignment.start,
92 children: [
93 Row(
94 mainAxisAlignment: MainAxisAlignment.spaceBetween,
95 children: [
96 Text(
97 title,
98 style: theme.textTheme.labelMedium?.copyWith(
99 color: theme.colorScheme.primary,
100 fontWeight: FontWeight.w600,
101 ),
102 ),
103 if (trailing != null) trailing,
104 ],
105 ),
106 const SizedBox(height: 8),
107 Container(
108 decoration: BoxDecoration(
109 color: theme.colorScheme.surfaceContainerLow,
110 borderRadius: BorderRadius.circular(8),
111 ),
112 child:
113 child ??
114 Column(
115 children: items!.map((item) {
116 final isLast = item == items.last;
117 return _InfoRow(
118 label: item.label,
119 value: item.value,
120 showDivider: !isLast,
121 theme: theme,
122 );
123 }).toList(),
124 ),
125 ),
126 ],
127 );
128 }
129
130 Widget _buildEmptyState(ThemeData theme, String message) {
131 return Padding(
132 padding: const EdgeInsets.all(12),
133 child: Text(
134 message,
135 style: theme.textTheme.bodySmall?.copyWith(
136 color: theme.colorScheme.onSurfaceVariant,
137 fontStyle: FontStyle.italic,
138 ),
139 ),
140 );
141 }
142
143 Color? _getFpsColor(double fps, ThemeData theme) {
144 if (fps >= 55) return Colors.green;
145 if (fps >= 30) return Colors.orange;
146 return theme.colorScheme.error;
147 }
148
149 double _calculateAvg(List<double> values) {
150 if (values.isEmpty) return 0;
151 return values.reduce((a, b) => a + b) / values.length;
152 }
153
154 double _calculateMax(List<double> values) {
155 if (values.isEmpty) return 0;
156 return values.reduce((a, b) => a > b ? a : b);
157 }
158
159 String _formatBytes(int bytes) {
160 if (bytes < 1024) return '$bytes B';
161 if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
162 return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
163 }
164}
165
166class _MetricCard extends StatelessWidget {
167 const _MetricCard({required this.label, required this.value, this.color});
168 final String label;
169 final String value;
170 final Color? color;
171
172 @override
173 Widget build(BuildContext context) {
174 final theme = Theme.of(context);
175 return Padding(
176 padding: const EdgeInsets.all(12),
177 child: Row(
178 mainAxisAlignment: MainAxisAlignment.spaceBetween,
179 children: [
180 Text(label, style: theme.textTheme.bodyMedium),
181 Text(
182 value,
183 style: theme.textTheme.titleMedium?.copyWith(
184 color: color ?? theme.colorScheme.onSurface,
185 fontWeight: FontWeight.bold,
186 fontFamily: 'monospace',
187 ),
188 ),
189 ],
190 ),
191 );
192 }
193}
194
195class _InfoRow extends StatelessWidget {
196 const _InfoRow({
197 required this.label,
198 required this.value,
199 required this.showDivider,
200 required this.theme,
201 });
202 final String label;
203 final String value;
204 final bool showDivider;
205 final ThemeData theme;
206
207 @override
208 Widget build(BuildContext context) {
209 return Column(
210 children: [
211 Padding(
212 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
213 child: Row(
214 mainAxisAlignment: MainAxisAlignment.spaceBetween,
215 children: [
216 Text(
217 label,
218 style: theme.textTheme.bodyMedium?.copyWith(
219 color: theme.colorScheme.onSurfaceVariant,
220 ),
221 ),
222 Text(
223 value,
224 style: theme.textTheme.bodyMedium?.copyWith(
225 fontFamily: 'monospace',
226 fontWeight: FontWeight.w500,
227 ),
228 ),
229 ],
230 ),
231 ),
232 if (showDivider)
233 Divider(
234 height: 1,
235 indent: 12,
236 endIndent: 12,
237 color: theme.colorScheme.outline.withValues(alpha: 0.1),
238 ),
239 ],
240 );
241 }
242}
243
244class _InfoItem {
245 const _InfoItem({required this.label, required this.value});
246 final String label;
247 final String value;
248}