Files
FlClash/lib/features/ip_quality/ip_quality_sheet.dart
T
chen08209 568c49bb3a feat(ui): layer surfaces with fills and follow the iOS type scale
Changelog: iOS-style surfaces, alerts and sheets; clearer TV focus
2026-10-16 22:54:53 +08:00

230 lines
6.8 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/ip_quality.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/ip_quality.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
import 'ip_quality_text.dart';
import 'labels.dart';
Future<void> showIpQualitySheet(BuildContext context, {required String ip}) {
if (isSheetPage(context)) {
return Navigator.of(
context,
).push(PagedSheetRoute(builder: (_) => _IpQualitySheet(ip: ip)));
}
return showSheet<void>(
context: context,
builder: (_) => _IpQualitySheet(ip: ip),
);
}
class _IpQualitySheet extends ConsumerWidget {
const _IpQualitySheet({required this.ip});
final String ip;
@override
Widget build(BuildContext context, WidgetRef ref) {
final localizations = context.appLocalizations;
final isLoading = ref.watch(
ipQualityProvider(ip).select((result) => result.isLoading),
);
return CommonScaffold(
title: localizations.outboundIp,
actions: [
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.refresh,
tooltip: localizations.ipQualityRetry,
isLoading: isLoading,
onPressed: () => ref.invalidate(ipQualityProvider(ip)),
),
),
],
body: _IpQualityDetail(ip: ip),
);
}
}
class _HideIpItem extends ConsumerWidget {
const _HideIpItem();
@override
Widget build(BuildContext context, WidgetRef ref) {
final hideIp = ref.watch(
appSettingProvider.select((state) => state.hideIp),
);
void update(bool value) => ref
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(hideIp: value));
return DecorationListItem(
title: Text(context.appLocalizations.hideIp),
onPressed: () => update(!hideIp),
trailing: Switch(value: hideIp, onChanged: update),
);
}
}
class _IpQualityDetail extends ConsumerWidget {
const _IpQualityDetail({required this.ip});
/// The optical edge of the xl-radius cards, as in the memory sheet.
static const _tipInset = 5.0;
final String ip;
@override
Widget build(BuildContext context, WidgetRef ref) {
final localizations = context.appLocalizations;
final result = ref.watch(ipQualityProvider(ip));
final quality = result.value;
final error = result.error;
final failed = quality == null && !result.isLoading;
return ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(_tipInset, 8, _tipInset, 0),
child: Text(
localizations.detectionTip,
style: AppTextStyle.body
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
generateSection(
title: localizations.basicInfo,
items: [
DetailRow(
title: localizations.ipAddress,
copyText: ip,
value: IpQualityText(ip: ip),
),
if (failed)
DetailRow(
title: localizations.ipType,
value: Text(
localizations.ipQualityFailed,
style: TextStyle(color: context.colorScheme.error),
),
)
else
..._qualityRows(context, quality),
],
),
if (!failed)
generateSection(
title: localizations.network,
items: _networkRows(context, quality),
),
if (failed && error is IpQualityLookupException)
generateSection(
title: localizations.ipQualitySources,
items: [
for (final failure in error.failures)
DetailRow(
title: failure.source.label,
value: Text(failure.status.label(context)),
),
],
),
generateSection(
title: localizations.other,
items: const [_HideIpItem()],
),
],
);
}
/// A lookup still running lays out every row it will fill, so the page
/// keeps its height when the values arrive.
List<Widget> _qualityRows(BuildContext context, IpQuality? quality) {
final localizations = context.appLocalizations;
if (quality == null) {
return [
DetailRow(
title: localizations.ipQualityLevel,
value: const SkeletonText(width: 40),
),
DetailRow(
title: localizations.ipType,
value: const SkeletonText(width: 64),
),
DetailRow(
title: localizations.ipFlags,
value: const SkeletonText(width: 40),
),
];
}
final flags = [
if (quality.isTor) localizations.ipFlagTor,
if (quality.isAbuser) localizations.ipFlagAbuser,
if (quality.isVpn) localizations.ipFlagVpn,
if (quality.isProxy) localizations.ipFlagProxy,
];
return [
DetailRow(
title: localizations.ipQualityLevel,
value: Text(
quality.level.label(context),
style: TextStyle(color: quality.level.color(context)),
),
),
DetailRow(
title: localizations.ipType,
value: Text(quality.type.label(context)),
),
DetailRow(
title: localizations.ipFlags,
value: flags.isEmpty
? Text(localizations.none)
: Wrap(
spacing: 6,
runSpacing: 4,
alignment: WrapAlignment.end,
children: [for (final flag in flags) MetaChip(label: flag)],
),
),
];
}
List<Widget> _networkRows(BuildContext context, IpQuality? quality) {
final localizations = context.appLocalizations;
Widget valueOf(String? text, {required double width}) =>
switch ((quality, text)) {
(null, _) => SkeletonText(width: width),
(_, final text?) => Text(text),
_ => const Text('—'),
};
final organization = quality?.organization;
final asn = switch (quality?.asn) {
final asn? => 'AS$asn',
null => null,
};
return [
DetailRow(
title: localizations.ipOrganization,
copyText: organization,
value: valueOf(organization, width: 120),
),
DetailRow(
title: localizations.ipAsn,
copyText: asn,
value: valueOf(asn, width: 56),
),
DetailRow(
title: localizations.ipQualitySource,
value: valueOf(quality?.source.label, width: 72),
),
];
}
}