neoagent 2.4.1-beta.10 → 2.4.1-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
+ import 'dart:convert';
2
+ import 'package:flutter/foundation.dart';
1
3
  import 'package:flutter/material.dart';
2
4
  import 'package:flutter_animate/flutter_animate.dart';
5
+ import 'package:http/http.dart' as http;
3
6
  import 'package:url_launcher/url_launcher.dart';
4
7
 
5
8
  import '../../main.dart';
@@ -21,6 +24,149 @@ class OnboardingCompanionStep extends StatefulWidget {
21
24
 
22
25
  class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
23
26
  final Set<String> _clickedDownloads = <String>{};
27
+ String _selectedChannel = 'stable';
28
+ TargetPlatform _selectedDesktopPlatform = TargetPlatform.macOS;
29
+
30
+ bool _isLoadingReleases = false;
31
+ Map<String, Map<TargetPlatform, String>> _downloadUrls = {
32
+ 'stable': <TargetPlatform, String>{},
33
+ 'beta': <TargetPlatform, String>{},
34
+ };
35
+
36
+ @override
37
+ void initState() {
38
+ super.initState();
39
+ switch (defaultTargetPlatform) {
40
+ case TargetPlatform.windows:
41
+ _selectedDesktopPlatform = TargetPlatform.windows;
42
+ break;
43
+ case TargetPlatform.linux:
44
+ _selectedDesktopPlatform = TargetPlatform.linux;
45
+ break;
46
+ default:
47
+ _selectedDesktopPlatform = TargetPlatform.macOS;
48
+ }
49
+ _fetchReleases();
50
+ }
51
+
52
+ Future<void> _fetchReleases() async {
53
+ if (mounted) {
54
+ setState(() {
55
+ _isLoadingReleases = true;
56
+ });
57
+ }
58
+ try {
59
+ final response = await http.get(
60
+ Uri.parse('https://api.github.com/repos/NeoLabs-Systems/NeoAgent/releases'),
61
+ headers: const <String, String>{
62
+ 'Accept': 'application/vnd.github.v3+json',
63
+ },
64
+ ).timeout(const Duration(seconds: 8));
65
+
66
+ if (response.statusCode == 200) {
67
+ final List<dynamic> releasesJson = jsonDecode(response.body) as List<dynamic>;
68
+ final urls = _parseReleases(releasesJson);
69
+ if (mounted) {
70
+ setState(() {
71
+ _downloadUrls = urls;
72
+ _isLoadingReleases = false;
73
+ });
74
+ }
75
+ } else {
76
+ throw Exception('Failed to load releases: status ${response.statusCode}');
77
+ }
78
+ } catch (e) {
79
+ debugPrint('Error fetching releases: $e');
80
+ if (mounted) {
81
+ setState(() {
82
+ _isLoadingReleases = false;
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ Map<String, Map<TargetPlatform, String>> _parseReleases(List<dynamic> releases) {
89
+ final Map<String, Map<TargetPlatform, String>> result = {
90
+ 'stable': <TargetPlatform, String>{},
91
+ 'beta': <TargetPlatform, String>{},
92
+ };
93
+
94
+ dynamic latestStable;
95
+ dynamic latestBeta;
96
+
97
+ for (final release in releases) {
98
+ if (release is! Map<String, dynamic>) continue;
99
+ final bool isPrerelease = release['prerelease'] == true;
100
+ final String tagName = (release['tag_name'] ?? '').toString();
101
+ final bool isBetaTag = tagName.contains('-beta');
102
+
103
+ if ((isPrerelease || isBetaTag) && latestBeta == null) {
104
+ latestBeta = release;
105
+ } else if (!isPrerelease && !isBetaTag && latestStable == null) {
106
+ latestStable = release;
107
+ }
108
+
109
+ if (latestStable != null && latestBeta != null) {
110
+ break;
111
+ }
112
+ }
113
+
114
+ latestStable ??= latestBeta;
115
+ latestBeta ??= latestStable;
116
+
117
+ if (latestStable != null) {
118
+ result['stable'] = _extractUrlsFromRelease(latestStable);
119
+ }
120
+ if (latestBeta != null) {
121
+ result['beta'] = _extractUrlsFromRelease(latestBeta);
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ Map<TargetPlatform, String> _extractUrlsFromRelease(dynamic release) {
128
+ final Map<TargetPlatform, String> urls = <TargetPlatform, String>{};
129
+ if (release is! Map<String, dynamic>) return urls;
130
+ final List<dynamic> assets = release['assets'] as List<dynamic>? ?? const <dynamic>[];
131
+
132
+ for (final asset in assets) {
133
+ if (asset is! Map<String, dynamic>) continue;
134
+ final String name = (asset['name'] ?? '').toString().toLowerCase();
135
+ final String downloadUrl = (asset['browser_download_url'] ?? '').toString();
136
+
137
+ if (name.endsWith('.dmg')) {
138
+ urls[TargetPlatform.macOS] = downloadUrl;
139
+ } else if (name.endsWith('.exe') && name.contains('setup')) {
140
+ urls[TargetPlatform.windows] = downloadUrl;
141
+ } else if (name.endsWith('.exe')) {
142
+ urls.putIfAbsent(TargetPlatform.windows, () => downloadUrl);
143
+ } else if (name.endsWith('.deb')) {
144
+ urls[TargetPlatform.linux] = downloadUrl;
145
+ } else if (name.endsWith('.apk') && !name.contains('-launcher')) {
146
+ urls[TargetPlatform.android] = downloadUrl;
147
+ }
148
+ }
149
+ return urls;
150
+ }
151
+
152
+ String _getFallbackUrl(String channel, TargetPlatform platform) {
153
+ final String tag = channel == 'beta' ? 'v2.4.1-beta.9' : 'v2.4.0';
154
+ final String ver = channel == 'beta' ? '2.4.1-beta.9' : '2.4.0';
155
+ final String debVer = ver.replaceAll('-', '~');
156
+
157
+ switch (platform) {
158
+ case TargetPlatform.macOS:
159
+ return 'https://github.com/NeoLabs-Systems/NeoAgent/releases/download/$tag/neoagent-macos-$ver.dmg';
160
+ case TargetPlatform.windows:
161
+ return 'https://github.com/NeoLabs-Systems/NeoAgent/releases/download/$tag/neoagent-windows-x64-setup-$ver.exe';
162
+ case TargetPlatform.linux:
163
+ return 'https://github.com/NeoLabs-Systems/NeoAgent/releases/download/$tag/neoagent-linux-amd64-$debVer.deb';
164
+ case TargetPlatform.android:
165
+ return 'https://github.com/NeoLabs-Systems/NeoAgent/releases/download/$tag/neoagent-android-$ver.apk';
166
+ default:
167
+ return 'https://github.com/NeoLabs-Systems/NeoAgent/releases/tag/$tag';
168
+ }
169
+ }
24
170
 
25
171
  Future<void> _launchUrl(String urlString) async {
26
172
  final url = Uri.parse(urlString);
@@ -29,6 +175,75 @@ class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
29
175
  }
30
176
  }
31
177
 
178
+ String _getFileExtensionForPlatform(TargetPlatform platform) {
179
+ switch (platform) {
180
+ case TargetPlatform.macOS:
181
+ return '.dmg';
182
+ case TargetPlatform.windows:
183
+ return '.exe';
184
+ case TargetPlatform.linux:
185
+ return '.deb';
186
+ default:
187
+ return '';
188
+ }
189
+ }
190
+
191
+ Widget _buildChannelSelector() {
192
+ return Container(
193
+ padding: const EdgeInsets.all(4),
194
+ decoration: BoxDecoration(
195
+ color: Colors.white.withValues(alpha: 0.05),
196
+ borderRadius: BorderRadius.circular(16),
197
+ border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
198
+ ),
199
+ child: Row(
200
+ mainAxisSize: MainAxisSize.min,
201
+ children: <Widget>[
202
+ _buildChannelButton('stable', 'Stable Release'),
203
+ _buildChannelButton('beta', 'Beta Release'),
204
+ ],
205
+ ),
206
+ );
207
+ }
208
+
209
+ Widget _buildChannelButton(String channel, String label) {
210
+ final isSelected = _selectedChannel == channel;
211
+ return Material(
212
+ color: Colors.transparent,
213
+ child: InkWell(
214
+ onTap: () {
215
+ setState(() {
216
+ _selectedChannel = channel;
217
+ });
218
+ },
219
+ borderRadius: BorderRadius.circular(12),
220
+ child: AnimatedContainer(
221
+ duration: const Duration(milliseconds: 200),
222
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
223
+ decoration: BoxDecoration(
224
+ color: isSelected
225
+ ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.16)
226
+ : Colors.transparent,
227
+ borderRadius: BorderRadius.circular(12),
228
+ border: Border.all(
229
+ color: isSelected
230
+ ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6)
231
+ : Colors.transparent,
232
+ ),
233
+ ),
234
+ child: Text(
235
+ label,
236
+ style: TextStyle(
237
+ color: isSelected ? Colors.white : Colors.white.withValues(alpha: 0.6),
238
+ fontSize: 14,
239
+ fontWeight: FontWeight.w700,
240
+ ),
241
+ ),
242
+ ),
243
+ ),
244
+ );
245
+ }
246
+
32
247
  @override
33
248
  Widget build(BuildContext context) {
34
249
  final width = MediaQuery.sizeOf(context).width;
@@ -62,10 +277,14 @@ class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
62
277
  icon: Icons.laptop_mac_rounded,
63
278
  accentColor: desktopConnected ? const Color(0xFF10A37F) : const Color(0xFF8A5CF5),
64
279
  connected: desktopConnected,
65
- buttonText: desktopConnected ? 'Desktop Connected' : 'Get Desktop App',
280
+ buttonText: desktopConnected
281
+ ? 'Desktop Connected'
282
+ : 'Download for ${_selectedDesktopPlatform.name.toUpperCase()} (${_getFileExtensionForPlatform(_selectedDesktopPlatform)})',
66
283
  onTap: () async {
67
284
  setState(() => _clickedDownloads.add('desktop'));
68
- await _launchUrl('https://github.com/NeoLabs-Systems/NeoAgent/releases');
285
+ final url = _downloadUrls[_selectedChannel]?[_selectedDesktopPlatform] ??
286
+ _getFallbackUrl(_selectedChannel, _selectedDesktopPlatform);
287
+ await _launchUrl(url);
69
288
  },
70
289
  ),
71
290
  _CompanionItemData(
@@ -78,7 +297,9 @@ class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
78
297
  buttonText: 'Download Android APK',
79
298
  onTap: () async {
80
299
  setState(() => _clickedDownloads.add('mobile'));
81
- await _launchUrl('https://github.com/NeoLabs-Systems/NeoAgent/releases');
300
+ final url = _downloadUrls[_selectedChannel]?[TargetPlatform.android] ??
301
+ _getFallbackUrl(_selectedChannel, TargetPlatform.android);
302
+ await _launchUrl(url);
82
303
  },
83
304
  ),
84
305
  ];
@@ -103,42 +324,79 @@ class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
103
324
  ),
104
325
  ],
105
326
  ),
106
- child: useGrid
107
- ? GridView.builder(
108
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
109
- crossAxisCount: columns,
110
- crossAxisSpacing: 14,
111
- mainAxisSpacing: 14,
112
- childAspectRatio: width >= 1050 ? 1.42 : 1.25,
113
- ),
114
- itemCount: items.length,
115
- itemBuilder: (context, index) {
116
- final item = items[index];
117
- return _CompanionCard(
118
- item: item,
119
- compact: true,
120
- isClicked: _clickedDownloads.contains(item.id),
121
- )
122
- .animate()
123
- .fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
124
- .slideY(begin: 0.16, end: 0);
125
- },
126
- )
127
- : ListView.separated(
128
- itemCount: items.length,
129
- separatorBuilder: (_, __) => const SizedBox(height: 14),
130
- itemBuilder: (context, index) {
131
- final item = items[index];
132
- return _CompanionCard(
133
- item: item,
134
- compact: false,
135
- isClicked: _clickedDownloads.contains(item.id),
136
- )
137
- .animate()
138
- .fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
139
- .slideY(begin: 0.16, end: 0);
140
- },
141
- ),
327
+ child: Column(
328
+ crossAxisAlignment: CrossAxisAlignment.start,
329
+ children: <Widget>[
330
+ Row(
331
+ mainAxisAlignment: MainAxisAlignment.center,
332
+ children: [
333
+ _buildChannelSelector(),
334
+ if (_isLoadingReleases) ...[
335
+ const SizedBox(width: 12),
336
+ const SizedBox(
337
+ width: 14,
338
+ height: 14,
339
+ child: CircularProgressIndicator(
340
+ strokeWidth: 2,
341
+ valueColor: AlwaysStoppedAnimation<Color>(Colors.white54),
342
+ ),
343
+ ),
344
+ ],
345
+ ],
346
+ ),
347
+ const SizedBox(height: 20),
348
+ Expanded(
349
+ child: useGrid
350
+ ? GridView.builder(
351
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
352
+ crossAxisCount: columns,
353
+ crossAxisSpacing: 14,
354
+ mainAxisSpacing: 14,
355
+ childAspectRatio: width >= 1050 ? 1.42 : 1.25,
356
+ ),
357
+ itemCount: items.length,
358
+ itemBuilder: (context, index) {
359
+ final item = items[index];
360
+ return _CompanionCard(
361
+ item: item,
362
+ compact: true,
363
+ isClicked: _clickedDownloads.contains(item.id),
364
+ selectedDesktopPlatform: _selectedDesktopPlatform,
365
+ onDesktopPlatformChanged: (platform) {
366
+ setState(() {
367
+ _selectedDesktopPlatform = platform;
368
+ });
369
+ },
370
+ )
371
+ .animate()
372
+ .fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
373
+ .slideY(begin: 0.16, end: 0);
374
+ },
375
+ )
376
+ : ListView.separated(
377
+ itemCount: items.length,
378
+ separatorBuilder: (_, __) => const SizedBox(height: 14),
379
+ itemBuilder: (context, index) {
380
+ final item = items[index];
381
+ return _CompanionCard(
382
+ item: item,
383
+ compact: false,
384
+ isClicked: _clickedDownloads.contains(item.id),
385
+ selectedDesktopPlatform: _selectedDesktopPlatform,
386
+ onDesktopPlatformChanged: (platform) {
387
+ setState(() {
388
+ _selectedDesktopPlatform = platform;
389
+ });
390
+ },
391
+ )
392
+ .animate()
393
+ .fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
394
+ .slideY(begin: 0.16, end: 0);
395
+ },
396
+ ),
397
+ ),
398
+ ],
399
+ ),
142
400
  );
143
401
  },
144
402
  );
@@ -172,11 +430,15 @@ class _CompanionCard extends StatelessWidget {
172
430
  required this.item,
173
431
  required this.compact,
174
432
  required this.isClicked,
433
+ required this.selectedDesktopPlatform,
434
+ required this.onDesktopPlatformChanged,
175
435
  });
176
436
 
177
437
  final _CompanionItemData item;
178
438
  final bool compact;
179
439
  final bool isClicked;
440
+ final TargetPlatform selectedDesktopPlatform;
441
+ final ValueChanged<TargetPlatform> onDesktopPlatformChanged;
180
442
 
181
443
  @override
182
444
  Widget build(BuildContext context) {
@@ -231,6 +493,11 @@ class _CompanionCard extends StatelessWidget {
231
493
  ),
232
494
  ),
233
495
  const SizedBox(height: 12),
496
+ if (item.id == 'desktop' && !item.connected)
497
+ _PlatformSelector(
498
+ selectedPlatform: selectedDesktopPlatform,
499
+ onPlatformChanged: onDesktopPlatformChanged,
500
+ ),
234
501
  _DownloadButton(item: item, isClicked: isClicked),
235
502
  ],
236
503
  )
@@ -274,6 +541,11 @@ class _CompanionCard extends StatelessWidget {
274
541
  ),
275
542
  ),
276
543
  const SizedBox(height: 14),
544
+ if (item.id == 'desktop' && !item.connected)
545
+ _PlatformSelector(
546
+ selectedPlatform: selectedDesktopPlatform,
547
+ onPlatformChanged: onDesktopPlatformChanged,
548
+ ),
277
549
  _DownloadButton(item: item, isClicked: isClicked),
278
550
  ],
279
551
  ),
@@ -357,13 +629,13 @@ class _DownloadButton extends StatelessWidget {
357
629
  return Container(
358
630
  padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
359
631
  decoration: BoxDecoration(
360
- color: isClicked
361
- ? Colors.white.withValues(alpha: 0.08)
632
+ color: isClicked
633
+ ? Colors.white.withValues(alpha: 0.08)
362
634
  : item.accentColor.withValues(alpha: 0.12),
363
635
  borderRadius: BorderRadius.circular(12),
364
636
  border: Border.all(
365
- color: isClicked
366
- ? Colors.white.withValues(alpha: 0.2)
637
+ color: isClicked
638
+ ? Colors.white.withValues(alpha: 0.2)
367
639
  : item.accentColor.withValues(alpha: 0.25),
368
640
  ),
369
641
  ),
@@ -389,3 +661,61 @@ class _DownloadButton extends StatelessWidget {
389
661
  );
390
662
  }
391
663
  }
664
+
665
+ class _PlatformSelector extends StatelessWidget {
666
+ const _PlatformSelector({
667
+ required this.selectedPlatform,
668
+ required this.onPlatformChanged,
669
+ });
670
+
671
+ final TargetPlatform selectedPlatform;
672
+ final ValueChanged<TargetPlatform> onPlatformChanged;
673
+
674
+ @override
675
+ Widget build(BuildContext context) {
676
+ return Container(
677
+ margin: const EdgeInsets.only(bottom: 12),
678
+ padding: const EdgeInsets.all(2),
679
+ decoration: BoxDecoration(
680
+ color: Colors.white.withValues(alpha: 0.04),
681
+ borderRadius: BorderRadius.circular(10),
682
+ border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
683
+ ),
684
+ child: Row(
685
+ mainAxisSize: MainAxisSize.min,
686
+ children: <Widget>[
687
+ _buildTab(TargetPlatform.macOS, 'macOS'),
688
+ _buildTab(TargetPlatform.windows, 'Windows'),
689
+ _buildTab(TargetPlatform.linux, 'Linux'),
690
+ ],
691
+ ),
692
+ );
693
+ }
694
+
695
+ Widget _buildTab(TargetPlatform platform, String label) {
696
+ final isSelected = selectedPlatform == platform;
697
+ return Material(
698
+ color: Colors.transparent,
699
+ child: InkWell(
700
+ onTap: () => onPlatformChanged(platform),
701
+ borderRadius: BorderRadius.circular(8),
702
+ child: AnimatedContainer(
703
+ duration: const Duration(milliseconds: 180),
704
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
705
+ decoration: BoxDecoration(
706
+ color: isSelected ? Colors.white.withValues(alpha: 0.08) : Colors.transparent,
707
+ borderRadius: BorderRadius.circular(8),
708
+ ),
709
+ child: Text(
710
+ label,
711
+ style: TextStyle(
712
+ color: isSelected ? Colors.white : Colors.white.withValues(alpha: 0.5),
713
+ fontSize: 11,
714
+ fontWeight: FontWeight.w700,
715
+ ),
716
+ ),
717
+ ),
718
+ ),
719
+ );
720
+ }
721
+ }
@@ -706,8 +706,8 @@ class _DevicesPanelState extends State<DevicesPanel> {
706
706
  onAction: _runQuickAction,
707
707
  ),
708
708
  if (kIsWeb) ...<Widget>[
709
- const SizedBox(height: 14),
710
- AndroidApkDropZone(
709
+ const SizedBox(height: 12),
710
+ _AndroidActionsBox(
711
711
  enabled: _androidOnline,
712
712
  busy: _isCurrentSurfaceBusy,
713
713
  onInstall: ({required filename, required bytes}) {
@@ -1264,6 +1264,77 @@ class _AndroidNavDock extends StatelessWidget {
1264
1264
  }
1265
1265
  }
1266
1266
 
1267
+ /// Tiny pill shown in the top-right corner of the preview to indicate no audio.
1268
+ class _MutedBadge extends StatelessWidget {
1269
+ const _MutedBadge();
1270
+
1271
+ @override
1272
+ Widget build(BuildContext context) {
1273
+ return Container(
1274
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
1275
+ decoration: BoxDecoration(
1276
+ color: Colors.black54,
1277
+ borderRadius: BorderRadius.circular(8),
1278
+ ),
1279
+ child: const Icon(Icons.volume_off_rounded, size: 11, color: Colors.white),
1280
+ );
1281
+ }
1282
+ }
1283
+
1284
+ /// Compact expandable actions box shown beneath the Android nav dock.
1285
+ /// Starts with APK install; more actions can be added as tiles.
1286
+ class _AndroidActionsBox extends StatelessWidget {
1287
+ const _AndroidActionsBox({
1288
+ required this.enabled,
1289
+ required this.busy,
1290
+ required this.onInstall,
1291
+ });
1292
+
1293
+ final bool enabled;
1294
+ final bool busy;
1295
+ final AndroidApkInstallCallback onInstall;
1296
+
1297
+ @override
1298
+ Widget build(BuildContext context) {
1299
+ return Container(
1300
+ width: double.infinity,
1301
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1302
+ decoration: BoxDecoration(
1303
+ color: _bgSecondary,
1304
+ borderRadius: BorderRadius.circular(18),
1305
+ border: Border.all(color: _borderLight),
1306
+ ),
1307
+ child: Column(
1308
+ crossAxisAlignment: CrossAxisAlignment.start,
1309
+ mainAxisSize: MainAxisSize.min,
1310
+ children: <Widget>[
1311
+ Text(
1312
+ 'ACTIONS',
1313
+ style: TextStyle(
1314
+ fontSize: 10,
1315
+ fontWeight: FontWeight.w700,
1316
+ letterSpacing: 0.8,
1317
+ color: _textSecondary,
1318
+ ),
1319
+ ),
1320
+ const SizedBox(height: 8),
1321
+ Wrap(
1322
+ spacing: 8,
1323
+ runSpacing: 8,
1324
+ children: <Widget>[
1325
+ AndroidApkTile(
1326
+ enabled: enabled,
1327
+ busy: busy,
1328
+ onInstall: onInstall,
1329
+ ),
1330
+ ],
1331
+ ),
1332
+ ],
1333
+ ),
1334
+ );
1335
+ }
1336
+ }
1337
+
1267
1338
  class _SurfaceSwitcher extends StatelessWidget {
1268
1339
  const _SurfaceSwitcher({required this.surface, required this.onSelect});
1269
1340
 
@@ -1636,6 +1707,14 @@ class _InteractiveSurfacePreviewState
1636
1707
  ? null
1637
1708
  : (x, y) => widget.onHover?.call(Offset(x, y)),
1638
1709
  ),
1710
+ const Positioned(
1711
+ top: 8,
1712
+ right: 8,
1713
+ child: Opacity(
1714
+ opacity: 0.45,
1715
+ child: _MutedBadge(),
1716
+ ),
1717
+ ),
1639
1718
  Positioned(
1640
1719
  left: 12,
1641
1720
  right: 12,
@@ -1743,6 +1822,14 @@ class _InteractiveSurfacePreviewState
1743
1822
  fit: BoxFit.contain,
1744
1823
  gaplessPlayback: true,
1745
1824
  ),
1825
+ const Positioned(
1826
+ top: 8,
1827
+ right: 8,
1828
+ child: Opacity(
1829
+ opacity: 0.45,
1830
+ child: _MutedBadge(),
1831
+ ),
1832
+ ),
1746
1833
  Positioned(
1747
1834
  left: 12,
1748
1835
  right: 12,
@@ -30,3 +30,27 @@ class AndroidApkDropZone extends StatelessWidget {
30
30
  );
31
31
  }
32
32
  }
33
+
34
+ /// Compact tile variant — fits inside an actions row.
35
+ class AndroidApkTile extends StatelessWidget {
36
+ const AndroidApkTile({
37
+ super.key,
38
+ required this.enabled,
39
+ required this.busy,
40
+ required this.onInstall,
41
+ });
42
+
43
+ final bool enabled;
44
+ final bool busy;
45
+ final AndroidApkInstallCallback onInstall;
46
+
47
+ @override
48
+ Widget build(BuildContext context) {
49
+ return buildAndroidApkTile(
50
+ context,
51
+ enabled: enabled,
52
+ busy: busy,
53
+ onInstall: onInstall,
54
+ );
55
+ }
56
+ }
@@ -14,3 +14,16 @@ Widget buildAndroidApkDropZone(
14
14
  }) {
15
15
  return const SizedBox.shrink();
16
16
  }
17
+
18
+ Widget buildAndroidApkTile(
19
+ BuildContext context, {
20
+ required bool enabled,
21
+ required bool busy,
22
+ required Future<void> Function({
23
+ required String filename,
24
+ required Uint8List bytes,
25
+ })
26
+ onInstall,
27
+ }) {
28
+ return const SizedBox.shrink();
29
+ }