Files
FlClash/lib/views/dashboard/dashboard.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

325 lines
10 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/core.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/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widget_metrics.dart';
import 'widget_registry.dart';
import 'widgets/core_status_button.dart';
import 'widgets/start_button.dart';
typedef _IsEditWidgetBuilder = Widget Function(bool isEdit);
class DashboardView extends ConsumerStatefulWidget {
const DashboardView({super.key});
@override
ConsumerState<DashboardView> createState() => _DashboardViewState();
}
class _DashboardViewState extends ConsumerState<DashboardView> {
final key = GlobalKey<SuperGridState>();
final _isEditNotifier = ValueNotifier<bool>(false);
final _addedWidgetsNotifier = ValueNotifier<List<GridItem>>([]);
@override
void initState() {
super.initState();
ref.listenManual(
dashboardStateProvider.select((state) => state.dashboardWidgets),
(_, dashboardWidgets) => _syncAddedWidgets(dashboardWidgets),
fireImmediately: true,
);
}
void _syncAddedWidgets(List<DashboardWidget> dashboardWidgets) {
bool onThisPlatform(DashboardWidget item) =>
item.platforms.contains(SupportPlatform.currentPlatform);
final shown = dashboardWidgets
.where(onThisPlatform)
.map((item) => item.widget)
.toSet();
_addedWidgetsNotifier.value = DashboardWidget.values
.where((item) => onThisPlatform(item) && !shown.contains(item.widget))
.map((item) => item.widget)
.toList();
}
@override
void dispose() {
_isEditNotifier.dispose();
_addedWidgetsNotifier.dispose();
super.dispose();
}
Widget _buildIsEdit(_IsEditWidgetBuilder builder) {
return ValueListenableBuilder(
valueListenable: _isEditNotifier,
builder: (_, isEdit, _) {
return builder(isEdit);
},
);
}
List<Widget> _buildActions(bool isEdit) {
return [
if (!isEdit && coreLib == null) const CoreStatusButton(),
if (isEdit)
ValueListenableBuilder(
valueListenable: _addedWidgetsNotifier,
builder: (_, addedChildren, child) {
if (addedChildren.isEmpty) {
return Container();
}
return child!;
},
child: IconButton(
tooltip: context.appLocalizations.addWidget,
onPressed: () {
_showAddWidgetsModal();
},
icon: const GlyphIcon(AppGlyphs.addCircle),
),
),
FadeRotationScaleBox(
child: isEdit
? IconButton(
tooltip: context.appLocalizations.save,
key: const ValueKey(true),
icon: const GlyphIcon(
AppGlyphs.save,
key: ValueKey('save-icon'),
),
onPressed: _handleExitEdit,
)
: IconButton(
tooltip: context.appLocalizations.edit,
key: const ValueKey(false),
icon: const GlyphIcon(
AppGlyphs.edit,
key: ValueKey('edit-icon'),
),
onPressed: _handleEnterEdit,
),
),
];
}
void _showAddWidgetsModal() {
showSnapSheet(
context,
detents: const [0.85],
builder: (sheetContext, scrollController) {
return ValueListenableBuilder(
valueListenable: _addedWidgetsNotifier,
builder: (_, value, _) {
return CommonScaffold(
body: _AddDashboardWidgetModal(
items: value,
scrollController: scrollController,
onAdd: (gridItem, from) =>
_addWidget(sheetContext, gridItem, from),
),
title: context.appLocalizations.add,
);
},
);
},
);
}
void _addWidget(BuildContext sheetContext, GridItem item, Rect from) {
final grid = key.currentState;
if (grid == null) {
return;
}
Navigator.of(sheetContext).pop();
grid.addItem(item, from: from);
}
void _handleEnterEdit() {
_isEditNotifier.value = true;
}
void _handleExitEdit() {
_isEditNotifier.value = false;
}
void _saveDashboardWidgets(List<GridItem> items) {
final dashboardWidgets = items.map(dashboardWidgetOf).toList();
ref
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(dashboardWidgets: dashboardWidgets));
}
@override
Widget build(BuildContext context) {
final dashboardState = ref.watch(dashboardStateProvider);
final hasProfile = ref.watch(
profilesProvider.select((state) => state.isNotEmpty),
);
final spacing = cardSpacing;
final children = [
...dashboardState.dashboardWidgets
.where(
(item) => item.platforms.contains(SupportPlatform.currentPlatform),
)
.map((item) => item.widget),
];
return _buildIsEdit(
(isEdit) => CommonScaffold(
title: context.appLocalizations.dashboard,
actions: _buildActions(isEdit),
floatingActionButton: hasProfile ? const StartButton() : null,
body: Builder(
builder: (context) {
final padding = const EdgeInsets.all(16).copyWith(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
);
return Align(
alignment: Alignment.topCenter,
// SingleChildScrollView snaps a bounce back to its edge whenever a
// card's refresh relays it out; a sliver viewport keeps the overscroll.
child: CustomScrollView(
slivers: [
SliverPadding(
padding: padding,
sliver: SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: dashboardMaxGridWidth,
),
child: LayoutBuilder(
builder: (_, constraints) {
final band = DashboardGridBand.of(
constraints.maxWidth,
);
final columns = band.columns;
final grid = SuperGrid(
key: key,
editing: isEdit,
crossAxisCount: columns,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
onChanged: _saveDashboardWidgets,
revealPadding: padding.copyWith(
left: 0,
right: 0,
),
children: children,
);
return DashboardWidgetMetrics(
unitHeight: dashboardUnitHeight(
constraints.maxWidth,
),
child: isEdit
? BackLayerScope(
onBack: _handleExitEdit,
child: grid,
)
: grid,
);
},
),
),
),
),
),
],
),
);
},
),
),
);
}
}
typedef _AddCallback = void Function(GridItem item, Rect from);
class _AddDashboardWidgetModal extends StatelessWidget {
final List<GridItem> items;
final ScrollController? scrollController;
final _AddCallback onAdd;
const _AddDashboardWidgetModal({
required this.items,
required this.scrollController,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
return DeferredPointerHandler(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(
16,
).copyWith(top: context.contentTopPadding),
child: Grid(
crossAxisCount: 8,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
children: items
.map(
(item) => item.wrap(
builder: (child) {
return _AddedContainer(
onAdd: (from) => onAdd(item, from),
child: child,
);
},
),
)
.toList(),
),
),
);
}
}
class _AddedContainer extends StatelessWidget {
final Widget child;
final ValueChanged<Rect> onAdd;
const _AddedContainer({required this.child, required this.onAdd});
void _handleAdd(BuildContext context) {
final box = context.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) {
return;
}
onAdd(box.localToGlobal(Offset.zero) & box.size);
}
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
ActivateBox(child: child),
Positioned(
top: -8,
right: -8,
child: DeferPointer(
child: ElasticButton(
child: CornerBadgeButton(
glyph: AppGlyphs.addCircle,
tooltip: context.appLocalizations.add,
onPressed: () => _handleAdd(context),
),
),
),
),
],
);
}
}