mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
perf(mornye): streamline download source and quality picker
This commit is contained in:
1 parent
a7c17ad316
commit
b33824e267
3 files changed
+466
-69
No files matched your search
@@ -0,0 +1,199 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:liquid_glass_easy/liquid_glass_easy.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
||||
|
||||
import 'performance_probe.dart';
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@override
|
||||
AppSettings build() => const AppSettings();
|
||||
}
|
||||
|
||||
class _Extensions extends ExtensionNotifier {
|
||||
@override
|
||||
ExtensionState build() => ExtensionState(
|
||||
extensions: [
|
||||
for (var source = 0; source < 4; source++)
|
||||
Extension(
|
||||
id: 'source-$source',
|
||||
name: 'source-$source',
|
||||
displayName: 'Source $source',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
hasDownloadProvider: true,
|
||||
qualityOptions: [
|
||||
for (var quality = 0; quality < 16; quality++)
|
||||
QualityOption(
|
||||
id: 'quality-$quality',
|
||||
label: 'Quality $quality',
|
||||
description: 'Example audio format',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
void refreshEnabledExtensionHealth({bool force = false}) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = PerformanceTestBinding.ensureInitialized();
|
||||
for (final brightness in Brightness.values) {
|
||||
testWidgets(
|
||||
'download glass, source changes and queue feedback in $brightness',
|
||||
(tester) async {
|
||||
final probe = PerformanceProbe(
|
||||
binding,
|
||||
tester,
|
||||
suite: 'download_picker_${brightness.name}',
|
||||
);
|
||||
expect(ui.ImageFilter.isShaderFilterSupported, isTrue);
|
||||
final capture = GlobalKey();
|
||||
(String, String)? selection;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
extensionProvider.overrideWith(_Extensions.new),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder: (_, child) => RepaintBoundary(
|
||||
key: capture,
|
||||
child: AppScaffoldMessenger(child: child!),
|
||||
),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xff76534b),
|
||||
Color(0xff385763),
|
||||
Color(0xff92836b),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: 'Example track',
|
||||
artistName: 'Example artist',
|
||||
onSelect: (quality, service) {
|
||||
selection = (quality, service);
|
||||
showQueuedSnackbar(context, 3, 0);
|
||||
},
|
||||
),
|
||||
child: const Text('Open download'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() => LiquidGlassShaders.ensureLoaded());
|
||||
await probe.wait(const Duration(milliseconds: 600));
|
||||
await tester.tap(find.text('Open download'));
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
expect(find.byType(MornyeGlassPanel), findsOneWidget);
|
||||
Future<void> savePose(String pose) async {
|
||||
await tester.runAsync(() async {
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find.byKey(capture),
|
||||
);
|
||||
final image = await boundary.toImage(
|
||||
pixelRatio: tester.view.devicePixelRatio,
|
||||
);
|
||||
try {
|
||||
final bytes = (await image.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
))!;
|
||||
final directory = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${directory.path}/download-${brightness.name}-$pose.png',
|
||||
);
|
||||
await file.writeAsBytes(
|
||||
bytes.buffer.asUint8List(
|
||||
bytes.offsetInBytes,
|
||||
bytes.lengthInBytes,
|
||||
),
|
||||
);
|
||||
debugPrint('DOWNLOAD_GLASS_POSE: ${file.path}');
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await savePose('panel');
|
||||
await probe.measure('change_sources', () async {
|
||||
for (final source in [1, 2, 3, 0]) {
|
||||
await tester.tap(find.text('Source $source'));
|
||||
await probe.wait(const Duration(milliseconds: 150));
|
||||
}
|
||||
});
|
||||
final scroll = tester.state<ScrollableState>(
|
||||
find.descendant(
|
||||
of: find.byType(DownloadServicePicker),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
await probe.measure('scroll_qualities', () async {
|
||||
await scroll.position.animateTo(
|
||||
scroll.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
await scroll.position.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
});
|
||||
await tester.tap(find.text('Quality 1'));
|
||||
await probe.wait(const Duration(milliseconds: 500));
|
||||
expect(selection, ('quality-1', 'source-0'));
|
||||
expect(find.byType(DownloadServicePicker), findsNothing);
|
||||
expect(find.byType(MornyeGlassPanel), findsOneWidget);
|
||||
await savePose('queue');
|
||||
await probe.finish(
|
||||
metadata: {
|
||||
'scope':
|
||||
'production download picker and queue feedback with fixture providers; no downloads',
|
||||
'brightness': brightness.name,
|
||||
},
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,19 +40,23 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
required void Function(String quality, String service) onSelect,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final mornye = context.isMornye;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: context.isMornye
|
||||
backgroundColor: mornye
|
||||
? Colors.transparent
|
||||
: colorScheme.surfaceContainerHigh,
|
||||
elevation: context.isMornye ? 0 : null,
|
||||
barrierColor: context.isMornye
|
||||
? Colors.black.withValues(alpha: 0.26)
|
||||
elevation: mornye ? 0 : null,
|
||||
// Only the glass draws the sheet outline, including its bottom corners.
|
||||
shape: mornye ? const RoundedRectangleBorder() : null,
|
||||
clipBehavior: mornye ? Clip.none : null,
|
||||
barrierColor: mornye
|
||||
? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context)
|
||||
: null,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: !context.isMornye,
|
||||
showDragHandle: !mornye,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.88,
|
||||
),
|
||||
@@ -65,16 +69,16 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
recommendedService: recommendedService,
|
||||
);
|
||||
return context.isMornye
|
||||
? MornyeGlassPanel.overlay(
|
||||
tintOpacity: colorScheme.brightness == Brightness.dark
|
||||
? 0.68
|
||||
: 0.75,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppSheetHandle(),
|
||||
Flexible(child: picker),
|
||||
],
|
||||
? Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: MornyeGlassPanel.overlay(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppSheetHandle(),
|
||||
Flexible(child: picker),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: picker;
|
||||
@@ -103,7 +107,12 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
|
||||
final notifier = ref.read(extensionProvider.notifier);
|
||||
for (final extension in _downloadExtensions()) {
|
||||
if (extension.hasServiceHealth) {
|
||||
unawaited(notifier.checkExtensionHealth(extension.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final recommended = widget.recommendedService;
|
||||
@@ -132,6 +141,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
}
|
||||
|
||||
void _selectService(Extension extension) {
|
||||
if (_selectedService == extension.id) return;
|
||||
setState(() => _selectedService = extension.id);
|
||||
if (extension.hasServiceHealth) {
|
||||
unawaited(
|
||||
@@ -143,8 +153,12 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final extensions = ref.watch(
|
||||
extensionProvider.select((state) => state.extensions),
|
||||
);
|
||||
final downloadExtensions = extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.toList(growable: false);
|
||||
final hasProviders = downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(downloadExtensions);
|
||||
final qualityRows = [
|
||||
@@ -199,10 +213,11 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
children: [
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
key: ValueKey(ext.id),
|
||||
label: ext.displayName,
|
||||
isRecommended: widget.recommendedService == ext.id,
|
||||
healthStatus: ext.hasServiceHealth
|
||||
? extensionState.healthStatuses[ext.id]?.status
|
||||
healthServiceId: ext.hasServiceHealth
|
||||
? ext.id
|
||||
: null,
|
||||
isSelected: _selectedService == ext.id,
|
||||
onTap: () => _selectService(ext),
|
||||
@@ -233,10 +248,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
if (context.isMornye)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Material(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: RepaintBoundary(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -407,15 +419,16 @@ class _ServiceChip extends StatelessWidget {
|
||||
final bool isRecommended;
|
||||
final VoidCallback? onTap;
|
||||
final String? iconPath;
|
||||
final String? healthStatus;
|
||||
final String? healthServiceId;
|
||||
|
||||
const _ServiceChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.isRecommended = false,
|
||||
this.iconPath,
|
||||
this.healthStatus,
|
||||
this.healthServiceId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -435,15 +448,25 @@ class _ServiceChip extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: isSelected
|
||||
? colorScheme.primary.withValues(alpha: 0.14)
|
||||
: MornyeTheme.controlFill(context),
|
||||
: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||
onPressed: onTap,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelected) ...[
|
||||
Icon(CupertinoIcons.checkmark, color: foreground, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// Reserve selection space so switching sources never reflows
|
||||
// the chips and changes the sheet height under the finger.
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: isSelected
|
||||
? Icon(
|
||||
CupertinoIcons.checkmark,
|
||||
color: foreground,
|
||||
size: 18,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (iconPath != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -451,6 +474,10 @@ class _ServiceChip extends StatelessWidget {
|
||||
File(iconPath!),
|
||||
width: 18,
|
||||
height: 18,
|
||||
cacheWidth: (18 * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
cacheHeight: (18 * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
CupertinoIcons.square_grid_2x2,
|
||||
@@ -466,7 +493,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -482,9 +509,9 @@ class _ServiceChip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (healthStatus != null) ...[
|
||||
if (healthServiceId != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_ServiceHealthDot(status: healthStatus!),
|
||||
_ServiceHealthDot(serviceId: healthServiceId!),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -510,8 +537,8 @@ class _ServiceChip extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (healthStatus != null) ...[
|
||||
_ServiceHealthDot(status: healthStatus!),
|
||||
if (healthServiceId != null) ...[
|
||||
_ServiceHealthDot(serviceId: healthServiceId!),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (iconPath != null) ...[
|
||||
@@ -521,6 +548,10 @@ class _ServiceChip extends StatelessWidget {
|
||||
File(iconPath!),
|
||||
width: 18,
|
||||
height: 18,
|
||||
cacheWidth: (18 * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
cacheHeight: (18 * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
@@ -549,13 +580,20 @@ class _ServiceChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceHealthDot extends StatelessWidget {
|
||||
final String status;
|
||||
class _ServiceHealthDot extends ConsumerWidget {
|
||||
final String serviceId;
|
||||
|
||||
const _ServiceHealthDot({required this.status});
|
||||
const _ServiceHealthDot({required this.serviceId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status =
|
||||
ref.watch(
|
||||
extensionProvider.select(
|
||||
(state) => state.healthStatuses[serviceId]?.status,
|
||||
),
|
||||
) ??
|
||||
'unknown';
|
||||
final color = _serviceHealthColor(status);
|
||||
return Tooltip(
|
||||
message: _serviceHealthTooltip(context, status),
|
||||
@@ -670,6 +708,8 @@ class _TrackInfoHeader extends StatefulWidget {
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
(double, TextStyle?, TextScaler, TextDirection, Locale?)? _titleLayout;
|
||||
bool _titleOverflows = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -697,6 +737,12 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
widget.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
cacheWidth:
|
||||
(56 * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
cacheHeight:
|
||||
(56 * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Container(
|
||||
@@ -727,22 +773,35 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(
|
||||
text: widget.trackName,
|
||||
style: titleStyle,
|
||||
final titleLayout = (
|
||||
constraints.maxWidth,
|
||||
titleStyle,
|
||||
MediaQuery.textScalerOf(context),
|
||||
Directionality.of(context),
|
||||
Localizations.maybeLocaleOf(context),
|
||||
);
|
||||
final titlePainter = TextPainter(
|
||||
text: titleSpan,
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
if (_titleLayout != titleLayout) {
|
||||
_titleLayout = titleLayout;
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: widget.trackName,
|
||||
style: titleStyle,
|
||||
),
|
||||
maxLines: 1,
|
||||
textScaler: titleLayout.$3,
|
||||
textDirection: titleLayout.$4,
|
||||
locale: titleLayout.$5,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
_titleOverflows = painter.didExceedMaxLines;
|
||||
painter.dispose();
|
||||
}
|
||||
if (_isOverflowing != _titleOverflows) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != _titleOverflows) {
|
||||
setState(() => _isOverflowing = _titleOverflows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -788,4 +847,13 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _TrackInfoHeader oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.trackName != widget.trackName) {
|
||||
_titleLayout = null;
|
||||
_expanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
@@ -9,32 +10,81 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
class _Extensions extends ExtensionNotifier {
|
||||
_Extensions(this._qualityCount);
|
||||
_Extensions(
|
||||
this._qualityCount, {
|
||||
this.serviceCount = 1,
|
||||
this.hasHealth = false,
|
||||
});
|
||||
|
||||
final int _qualityCount;
|
||||
final int serviceCount;
|
||||
final bool hasHealth;
|
||||
final healthRequests = <String>[];
|
||||
|
||||
@override
|
||||
ExtensionState build() => ExtensionState(
|
||||
extensions: [
|
||||
Extension(
|
||||
id: 'example',
|
||||
name: 'example',
|
||||
displayName: 'Example',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
hasDownloadProvider: true,
|
||||
qualityOptions: List.generate(
|
||||
_qualityCount,
|
||||
(index) => QualityOption(id: '$index', label: 'Quality $index'),
|
||||
for (var service = 0; service < serviceCount; service++)
|
||||
Extension(
|
||||
id: service == 0 ? 'example' : 'example-$service',
|
||||
name: 'example',
|
||||
displayName: service == 0 ? 'Example' : 'Source $service',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
hasDownloadProvider: true,
|
||||
serviceHealth: hasHealth
|
||||
? const [
|
||||
ExtensionServiceHealthCheck(
|
||||
id: 'health',
|
||||
url: 'https://example.com/health',
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
qualityOptions: List.generate(
|
||||
_qualityCount,
|
||||
(index) => QualityOption(id: '$index', label: 'Quality $index'),
|
||||
),
|
||||
),
|
||||
if (hasHealth)
|
||||
const Extension(
|
||||
id: 'metadata-only',
|
||||
name: 'metadata-only',
|
||||
displayName: 'Metadata source',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
enabled: true,
|
||||
status: 'loaded',
|
||||
hasMetadataProvider: true,
|
||||
serviceHealth: [
|
||||
ExtensionServiceHealthCheck(
|
||||
id: 'health',
|
||||
url: 'https://example.com/health',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
void refreshEnabledExtensionHealth({bool force = false}) {}
|
||||
|
||||
void publishHealth(String id, String status) => state = state.copyWith(
|
||||
healthStatuses: {
|
||||
...state.healthStatuses,
|
||||
id: ExtensionHealthStatus(extensionId: id, status: status),
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ExtensionHealthStatus?> checkExtensionHealth(
|
||||
String extensionId, {
|
||||
bool force = false,
|
||||
}) async {
|
||||
healthRequests.add(extensionId);
|
||||
return state.healthStatuses[extensionId];
|
||||
}
|
||||
}
|
||||
|
||||
class _Settings extends SettingsNotifier {
|
||||
@@ -126,4 +176,84 @@ void main() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (final brightness in Brightness.values) {
|
||||
testWidgets(
|
||||
'source status stays local and selection keeps chip geometry in $brightness',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(393, 852);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final extensions = _Extensions(3, serviceCount: 2, hasHealth: true);
|
||||
(String, String)? selection;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
extensionProvider.overrideWith(() => extensions),
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: 'Example track',
|
||||
onSelect: (quality, service) =>
|
||||
selection = (quality, service),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
final qualityBefore = tester.widget(find.text('Quality 0'));
|
||||
expect(extensions.healthRequests, ['example', 'example-1']);
|
||||
final titleBefore = tester.widget(find.text('Example track'));
|
||||
final source1 = find.ancestor(
|
||||
of: find.text('Example'),
|
||||
matching: find.byType(CupertinoButton),
|
||||
);
|
||||
final source2 = find.ancestor(
|
||||
of: find.text('Source 1'),
|
||||
matching: find.byType(CupertinoButton),
|
||||
);
|
||||
final sourceRects = [tester.getRect(source1), tester.getRect(source2)];
|
||||
extensions.publishHealth('example', 'offline');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Service offline'), findsOneWidget);
|
||||
expect(
|
||||
identical(tester.widget(find.text('Quality 0')), qualityBefore),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
identical(tester.widget(find.text('Example track')), titleBefore),
|
||||
isTrue,
|
||||
);
|
||||
expect([tester.getRect(source1), tester.getRect(source2)], sourceRects);
|
||||
await tester.tap(find.text('Example'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
identical(tester.widget(find.text('Quality 0')), qualityBefore),
|
||||
isTrue,
|
||||
);
|
||||
expect(extensions.healthRequests, ['example', 'example-1']);
|
||||
await tester.tap(find.text('Source 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect([tester.getRect(source1), tester.getRect(source2)], sourceRects);
|
||||
await tester.tap(find.text('Quality 1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selection, ('1', 'example-1'));
|
||||
expect(find.byType(DownloadServicePicker), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user