Files
FlClash/lib/views/about.dart
T
chen08209 491fae10f5 feat(views)!: set DNS, NTP, sniffer and TUN key by key
BREAKING CHANGE: DNS and NTP override toggles are removed; picked keys apply to every profile
Changelog: Per-key DNS, NTP, sniffer and TUN settings
2026-10-12 10:34:14 +08:00

320 lines
9.0 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/list.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@immutable
class Contributor {
final String avatar;
final String name;
const Contributor({required this.avatar, required this.name});
}
class AboutView extends ConsumerWidget {
const AboutView({super.key});
static const _contributors = [
Contributor(avatar: 'assets/images/avatar/june2.jpg', name: 'June2'),
Contributor(avatar: 'assets/images/avatar/arue.jpg', name: 'Arue'),
];
Future<void> _checkUpdate(BuildContext context, WidgetRef ref) async {
if (ref.read(loadingProvider(LoadingTag.checkUpdate))) return;
final commonAction = ref.read(commonActionProvider.notifier);
final result = await globalState.loadingRun(
() async => (release: await request.checkForUpdate()),
title: context.appLocalizations.checkUpdate,
tag: LoadingTag.checkUpdate,
);
if (result == null) return;
unawaited(
commonAction.checkUpdateResultHandle(data: result.release, isUser: true),
);
}
Widget _buildLinkItem({
required Glyph glyph,
required String title,
required String url,
required String label,
}) {
return ListItem(
leading: _LinkBadge(glyph: glyph),
title: Text(title),
subtitle: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: const GlyphIcon(AppGlyphs.openExternal),
onTap: () {
dialogs.openUrl(url);
},
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
final isLoading = ref.watch(loadingProvider(LoadingTag.checkUpdate));
return CommonScaffold(
isLoading: isLoading,
title: appLocalizations.about,
body: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 32),
children: [
_AboutHero(
isCheckingUpdate: isLoading,
onCheckUpdate: () {
_checkUpdate(context, ref);
},
onEnterDeveloperMode: () {
ref
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(developerMode: true));
context.showNotifier(
appLocalizations.developerModeEnableTip,
level: MessageLevel.success,
);
},
),
const SizedBox(height: 8),
generateSectionV3(
title: appLocalizations.more,
items: [
_buildLinkItem(
glyph: AppGlyphs.code,
title: appLocalizations.project,
url: 'https://github.com/$repository',
label: 'github.com/$repository',
),
_buildLinkItem(
glyph: AppGlyphs.cpu,
title: appLocalizations.core,
url: 'https://github.com/chen08209/Clash.Meta/tree/FlClash',
label: 'github.com/chen08209/Clash.Meta',
),
_buildLinkItem(
glyph: AppGlyphs.send,
title: 'Telegram',
url: 'https://t.me/FlClash',
label: 't.me/FlClash',
),
],
),
generateSectionV3(
title: appLocalizations.otherContributors,
items: [
for (final contributor in _contributors)
ListItem(
leading: CircleAvatar(
foregroundImage: AssetImage(contributor.avatar),
),
title: Text(contributor.name),
subtitle: Text(appLocalizations.appIconDesign),
),
],
),
],
),
);
}
}
class _AboutHero extends StatelessWidget {
final bool isCheckingUpdate;
final VoidCallback onCheckUpdate;
final VoidCallback onEnterDeveloperMode;
const _AboutHero({
required this.isCheckingUpdate,
required this.onCheckUpdate,
required this.onEnterDeveloperMode,
});
static const _logoSize = 96.0;
static const _logoInset = 14.0;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
final appLocalizations = context.appLocalizations;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
child: Column(
children: [
_DeveloperModeDetector(
onEnterDeveloperMode: onEnterDeveloperMode,
child: DecoratedBox(
decoration: ShapeDecoration(
color: colorScheme.surfaceContainerHigh,
shape: AppShape.all(AppCorner.fit(_logoSize)),
),
child: Padding(
padding: const EdgeInsets.all(_logoInset),
child: Image.asset(
'assets/images/icon.png',
width: _logoSize - _logoInset * 2,
height: _logoSize - _logoInset * 2,
),
),
),
),
const SizedBox(height: 20),
Text(
appName,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_Pill(
label: 'v${globalState.packageInfo.version}',
color: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
_Pill(
label: 'GPL-3.0',
color: colorScheme.surfaceContainerHighest,
foregroundColor: colorScheme.onSurfaceVariant,
),
],
),
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Text(
appLocalizations.desc,
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
),
const SizedBox(height: 20),
FilledButton.tonalIcon(
onPressed: isCheckingUpdate ? null : onCheckUpdate,
icon: const GlyphIcon(AppGlyphs.sync, fill: 1),
label: Text(appLocalizations.checkUpdate),
),
],
),
);
}
}
class _Pill extends StatelessWidget {
final String label;
final Color color;
final Color foregroundColor;
const _Pill({
required this.label,
required this.color,
required this.foregroundColor,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: ShapeDecoration(color: color, shape: AppShape.full),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _LinkBadge extends StatelessWidget {
final Glyph glyph;
const _LinkBadge({required this.glyph});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: colorScheme.secondaryContainer,
shape: AppShape.md,
),
child: SizedBox.square(
dimension: 40,
child: Center(
child: GlyphIcon(
glyph,
size: 20,
color: colorScheme.onSecondaryContainer,
),
),
),
);
}
}
class _DeveloperModeDetector extends StatefulWidget {
final Widget child;
final VoidCallback onEnterDeveloperMode;
const _DeveloperModeDetector({
required this.child,
required this.onEnterDeveloperMode,
});
@override
State<_DeveloperModeDetector> createState() => _DeveloperModeDetectorState();
}
class _DeveloperModeDetectorState extends State<_DeveloperModeDetector> {
int _counter = 0;
Timer? _timer;
void _handleTap() {
_counter++;
if (_counter >= 5) {
widget.onEnterDeveloperMode();
_resetCounter();
} else {
_timer?.cancel();
_timer = Timer(const Duration(seconds: 1), _resetCounter);
}
}
void _resetCounter() {
_counter = 0;
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(onTap: _handleTap, child: widget.child);
}
}