2ea52fdfdc
Changelog: Proxy groups as cards, group tabs as chips
352 lines
10 KiB
Dart
352 lines
10 KiB
Dart
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:fl_clash/providers/providers.dart';
|
|
import 'package:fl_clash/state.dart';
|
|
import 'package:fl_clash/ui/icons/icons.dart';
|
|
import 'package:fl_clash/widgets/widgets.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
void selectGroupProxy(
|
|
WidgetRef ref, {
|
|
required String groupName,
|
|
required GroupType groupType,
|
|
required String proxyName,
|
|
}) {
|
|
final isComputedSelected = groupType.isComputedSelected;
|
|
if (isComputedSelected || groupType == GroupType.Selector) {
|
|
final currentProxyName = ref.read(proxyNameProvider(groupName));
|
|
final nextProxyName = switch (isComputedSelected) {
|
|
true => currentProxyName == proxyName ? '' : proxyName,
|
|
false => proxyName,
|
|
};
|
|
ref
|
|
.read(proxiesActionProvider.notifier)
|
|
.changeProxyDebounce(groupName, nextProxyName);
|
|
return;
|
|
}
|
|
dialogs.showNotifier(
|
|
currentAppLocalizations.notSelectedTip,
|
|
level: MessageLevel.warning,
|
|
);
|
|
}
|
|
|
|
class ProxyCard extends ConsumerWidget {
|
|
final String groupName;
|
|
final Proxy proxy;
|
|
final GroupType groupType;
|
|
final ProxyCardType type;
|
|
final String? testUrl;
|
|
|
|
const ProxyCard({
|
|
super.key,
|
|
required this.groupName,
|
|
required this.testUrl,
|
|
required this.proxy,
|
|
required this.groupType,
|
|
required this.type,
|
|
});
|
|
|
|
Measure get measure => globalState.measure;
|
|
|
|
Widget _buildProxyNameText(BuildContext context) {
|
|
if (type == ProxyCardType.compact) {
|
|
return SizedBox(
|
|
height: measure.textHeight(AppTextStyle.subheadline) * 1,
|
|
child: EmojiText(
|
|
proxy.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: AppTextStyle.subheadline.of(context),
|
|
),
|
|
);
|
|
} else {
|
|
return SizedBox(
|
|
height: measure.textHeight(AppTextStyle.subheadline) * 2,
|
|
child: EmojiText(
|
|
proxy.name,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: AppTextStyle.subheadline.of(context),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final measure = globalState.measure;
|
|
final isLoose = type == ProxyCardType.loose;
|
|
final delayText = _DelayText(
|
|
proxy: proxy,
|
|
testUrl: testUrl,
|
|
alignment: isLoose ? Alignment.centerLeft : Alignment.centerRight,
|
|
);
|
|
final proxyNameText = _buildProxyNameText(context);
|
|
final typeStyle = AppTextStyle.footnote
|
|
.of(context)
|
|
.copyWith(
|
|
overflow: TextOverflow.ellipsis,
|
|
color: context.colorScheme.onSurface.opacity80,
|
|
);
|
|
final details = isLoose
|
|
? Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
height: measure.textHeight(AppTextStyle.footnote),
|
|
child: _ProxyDesc(proxy: proxy, style: typeStyle),
|
|
),
|
|
const SizedBox(height: 6),
|
|
delayText,
|
|
],
|
|
)
|
|
: SizedBox(
|
|
height: measure.textHeight(AppTextStyle.footnote),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Flexible(
|
|
flex: 1,
|
|
child: TooltipText(
|
|
text: Text(proxy.type, maxLines: 1, style: typeStyle),
|
|
),
|
|
),
|
|
delayText,
|
|
],
|
|
),
|
|
);
|
|
return Consumer(
|
|
builder: (context, ref, _) {
|
|
final inUse = ref.watch(
|
|
selectedProxyNameProvider(
|
|
groupName,
|
|
).select((name) => name == proxy.name),
|
|
);
|
|
final pinned =
|
|
groupType.isComputedSelected &&
|
|
ref.watch(
|
|
proxyNameProvider(groupName).select((name) => name == proxy.name),
|
|
);
|
|
return CommonCard(
|
|
radius: AppCorner.lg,
|
|
enterActionsOnRight: true,
|
|
key: key,
|
|
onPressed: () {
|
|
selectGroupProxy(
|
|
ref,
|
|
groupName: groupName,
|
|
groupType: groupType,
|
|
proxyName: proxy.name,
|
|
);
|
|
},
|
|
isSelected: inUse,
|
|
child: Container(
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
spacing: 4,
|
|
children: [
|
|
Expanded(child: proxyNameText),
|
|
if (pinned)
|
|
SelectionBadge.preferred(inUse: inUse)
|
|
else if (inUse)
|
|
const SelectionBadge(),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
details,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProxyDesc extends ConsumerWidget {
|
|
const _ProxyDesc({required this.proxy, required this.style});
|
|
|
|
final Proxy proxy;
|
|
final TextStyle style;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final isGroup = GroupType.values.any((type) => type.name == proxy.type);
|
|
final nodeName = isGroup
|
|
? ref.watch(
|
|
realSelectedProxyStateProvider(
|
|
proxy.name,
|
|
).select((state) => state.proxyName),
|
|
)
|
|
: proxy.name;
|
|
return EmojiText(
|
|
nodeName == proxy.name ? proxy.type : '${proxy.type}($nodeName)',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: style,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DelayText extends ConsumerStatefulWidget {
|
|
const _DelayText({
|
|
required this.proxy,
|
|
required this.testUrl,
|
|
required this.alignment,
|
|
});
|
|
|
|
final Proxy proxy;
|
|
final String? testUrl;
|
|
final Alignment alignment;
|
|
|
|
@override
|
|
ConsumerState<_DelayText> createState() => _DelayTextState();
|
|
}
|
|
|
|
class _DelayTextState extends ConsumerState<_DelayText> {
|
|
final FocusNode _focusNode = SkipTraversalFocusNode();
|
|
bool _isFocused = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusNode.addListener(_handleFocusChange);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.removeListener(_handleFocusChange);
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleFocusChange() {
|
|
final isFocused = _focusNode.hasPrimaryFocus;
|
|
if (isFocused == _isFocused) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isFocused = isFocused;
|
|
});
|
|
}
|
|
|
|
void _handleTestCurrentDelay() {
|
|
ref
|
|
.read(proxiesActionProvider.notifier)
|
|
.proxyDelayTest(widget.proxy, widget.testUrl);
|
|
}
|
|
|
|
Widget _withFocusRing(BuildContext context, Widget child) {
|
|
if (!_isFocused) {
|
|
return child;
|
|
}
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
child,
|
|
Positioned(
|
|
top: -2,
|
|
right: -3,
|
|
bottom: -2,
|
|
left: -3,
|
|
child: IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: ShapeDecoration(
|
|
shape: AppShape.xs.copyWith(
|
|
side: focusRingSide(context.colorScheme),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final measure = globalState.measure;
|
|
final delay = ref.watch(
|
|
delayProvider(proxyName: widget.proxy.name, testUrl: widget.testUrl),
|
|
);
|
|
final phase = ref.watch(
|
|
delayTestPhaseProvider(
|
|
proxyName: widget.proxy.name,
|
|
testUrl: widget.testUrl,
|
|
),
|
|
);
|
|
return Actions(
|
|
actions: {
|
|
ActivateIntent: CallbackAction<ActivateIntent>(
|
|
onInvoke: (_) {
|
|
_handleTestCurrentDelay();
|
|
return null;
|
|
},
|
|
),
|
|
},
|
|
child: Focus(
|
|
focusNode: _focusNode,
|
|
child: SizedBox(
|
|
height: measure.textHeight(AppTextStyle.caption2),
|
|
child: FadeBox(
|
|
alignment: widget.alignment,
|
|
child: phase != null || delay == null
|
|
? SizedBox(
|
|
height: measure.textHeight(AppTextStyle.caption2),
|
|
width: measure.textHeight(AppTextStyle.caption2),
|
|
child: _withFocusRing(context, switch (phase) {
|
|
DelayTestPhase.running => CommonCircleLoading(
|
|
size: measure.textHeight(AppTextStyle.caption2),
|
|
),
|
|
DelayTestPhase.queued => GlyphIcon(
|
|
AppGlyphs.clock,
|
|
size: measure.textHeight(AppTextStyle.caption2),
|
|
color: context.colorScheme.onSurfaceVariant.opacity38,
|
|
),
|
|
null => Tooltip(
|
|
message: context.appLocalizations.delayTest,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: _handleTestCurrentDelay,
|
|
child: GlyphIcon(
|
|
AppGlyphs.bolt,
|
|
size: measure.textHeight(AppTextStyle.caption2),
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
)
|
|
: GestureDetector(
|
|
onTap: _handleTestCurrentDelay,
|
|
child: _withFocusRing(
|
|
context,
|
|
Text(
|
|
delay > 0
|
|
? '$delay ms'
|
|
: delayFailureText(delay, context.appLocalizations),
|
|
maxLines: 1,
|
|
style: AppTextStyle.caption2
|
|
.of(context, emphasized: true)
|
|
.copyWith(
|
|
overflow: TextOverflow.ellipsis,
|
|
color: context.colorScheme.delayColor(delay),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|