805 lines
25 KiB
Dart
805 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:fl_clash/plugins/app.dart';
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/providers/providers.dart';
|
|
import 'package:fl_clash/state.dart';
|
|
import 'package:fl_clash/widgets/widgets.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
class AccessFragment extends ConsumerStatefulWidget {
|
|
const AccessFragment({super.key});
|
|
|
|
@override
|
|
ConsumerState<AccessFragment> createState() => _AccessFragmentState();
|
|
}
|
|
|
|
class _AccessFragmentState extends ConsumerState<AccessFragment> {
|
|
List<String> acceptList = [];
|
|
List<String> rejectList = [];
|
|
late ScrollController _controller;
|
|
final _completer = Completer();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_updateInitList();
|
|
_controller = ScrollController();
|
|
_completer.complete(globalState.appController.getPackages());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
_updateInitList() {
|
|
acceptList = globalState.config.vpnProps.accessControl.acceptList;
|
|
rejectList = globalState.config.vpnProps.accessControl.rejectList;
|
|
}
|
|
|
|
Widget _buildSearchButton() {
|
|
return IconButton(
|
|
tooltip: appLocalizations.search,
|
|
onPressed: () {
|
|
showSearch(
|
|
context: context,
|
|
delegate: AccessControlSearchDelegate(
|
|
acceptList: acceptList,
|
|
rejectList: rejectList,
|
|
),
|
|
).then(
|
|
(_) => setState(
|
|
() {
|
|
_updateInitList();
|
|
},
|
|
),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.search),
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectedAllButton({
|
|
required bool isSelectedAll,
|
|
required List<String> allValueList,
|
|
}) {
|
|
final tooltip = isSelectedAll
|
|
? appLocalizations.cancelSelectAll
|
|
: appLocalizations.selectAll;
|
|
return IconButton(
|
|
tooltip: tooltip,
|
|
onPressed: () {
|
|
ref.read(vpnSettingProvider.notifier).updateState((state) {
|
|
final isAccept =
|
|
state.accessControl.mode == AccessControlMode.acceptSelected;
|
|
if (isSelectedAll) {
|
|
return switch (isAccept) {
|
|
true => state.copyWith.accessControl(
|
|
acceptList: [],
|
|
),
|
|
false => state.copyWith.accessControl(
|
|
rejectList: [],
|
|
),
|
|
};
|
|
} else {
|
|
return switch (isAccept) {
|
|
true => state.copyWith.accessControl(
|
|
acceptList: allValueList,
|
|
),
|
|
false => state.copyWith.accessControl(
|
|
rejectList: allValueList,
|
|
),
|
|
};
|
|
}
|
|
});
|
|
},
|
|
icon: isSelectedAll
|
|
? const Icon(Icons.deselect)
|
|
: const Icon(Icons.select_all),
|
|
);
|
|
}
|
|
|
|
_intelligentSelected() async {
|
|
final packageNames = ref.read(
|
|
packageListSelectorStateProvider.select(
|
|
(state) => state.list.map((item) => item.packageName),
|
|
),
|
|
);
|
|
final commonScaffoldState = context.commonScaffoldState;
|
|
if (commonScaffoldState?.mounted != true) return;
|
|
final selectedPackageNames =
|
|
(await commonScaffoldState?.loadingRun<List<String>>(
|
|
() async {
|
|
return await app?.getChinaPackageNames() ?? [];
|
|
},
|
|
))
|
|
?.toSet() ??
|
|
{};
|
|
final acceptList = packageNames
|
|
.where((item) => !selectedPackageNames.contains(item))
|
|
.toList();
|
|
final rejectList = packageNames
|
|
.where((item) => selectedPackageNames.contains(item))
|
|
.toList();
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.accessControl(
|
|
acceptList: acceptList,
|
|
rejectList: rejectList,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSettingButton() {
|
|
return IconButton(
|
|
onPressed: () async {
|
|
final res = await showSheet<int>(
|
|
context: context,
|
|
props: SheetProps(
|
|
isScrollControlled: true,
|
|
),
|
|
builder: (_, type) {
|
|
return AdaptiveSheetScaffold(
|
|
type: type,
|
|
body: AccessControlPanel(),
|
|
title: appLocalizations.proxiesSetting,
|
|
);
|
|
},
|
|
);
|
|
if (res == 1) {
|
|
_intelligentSelected();
|
|
}
|
|
},
|
|
icon: const Icon(Icons.tune),
|
|
);
|
|
}
|
|
|
|
_handleSelected(List<String> valueList, Package package, bool? value) {
|
|
if (value == true) {
|
|
valueList.add(package.packageName);
|
|
} else {
|
|
valueList.remove(package.packageName);
|
|
}
|
|
ref.read(vpnSettingProvider.notifier).updateState((state) {
|
|
return switch (
|
|
state.accessControl.mode == AccessControlMode.acceptSelected) {
|
|
true => state.copyWith.accessControl(
|
|
acceptList: valueList,
|
|
),
|
|
false => state.copyWith.accessControl(
|
|
rejectList: valueList,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = ref.watch(packageListSelectorStateProvider);
|
|
final accessControl = state.accessControl;
|
|
final accessControlMode = accessControl.mode;
|
|
final packages = state.getSortList(
|
|
accessControlMode == AccessControlMode.acceptSelected
|
|
? acceptList
|
|
: rejectList,
|
|
);
|
|
final currentList = accessControl.currentList;
|
|
final packageNameList = packages.map((e) => e.packageName).toList();
|
|
final valueList = currentList.intersection(packageNameList);
|
|
final describe = accessControlMode == AccessControlMode.acceptSelected
|
|
? appLocalizations.accessControlAllowDesc
|
|
: appLocalizations.accessControlNotAllowDesc;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
Flexible(
|
|
flex: 0,
|
|
child: ListItem.switchItem(
|
|
title: Text(appLocalizations.appAccessControl),
|
|
delegate: SwitchDelegate(
|
|
value: accessControl.enable,
|
|
onChanged: (enable) {
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.accessControl(
|
|
enable: enable,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
child: Divider(
|
|
height: 12,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: DisabledMask(
|
|
status: !accessControl.enable,
|
|
child: Column(
|
|
children: [
|
|
ActivateBox(
|
|
active: accessControl.enable,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
top: 4,
|
|
bottom: 4,
|
|
left: 16,
|
|
right: 8,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
Expanded(
|
|
child: IntrinsicHeight(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
appLocalizations.selected,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.labelLarge
|
|
?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.primary,
|
|
),
|
|
),
|
|
),
|
|
const Flexible(
|
|
child: SizedBox(
|
|
width: 8,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
"${valueList.length}",
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.labelLarge
|
|
?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(describe),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Flexible(
|
|
child: _buildSearchButton(),
|
|
),
|
|
Flexible(
|
|
child: _buildSelectedAllButton(
|
|
isSelectedAll:
|
|
valueList.length == packageNameList.length,
|
|
allValueList: packageNameList,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: _buildSettingButton(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: FutureBuilder(
|
|
future: _completer.future,
|
|
builder: (_, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
}
|
|
return packages.isEmpty
|
|
? NullStatus(
|
|
label: appLocalizations.noData,
|
|
)
|
|
: CommonScrollBar(
|
|
controller: _controller,
|
|
child: ListView.builder(
|
|
controller: _controller,
|
|
itemCount: packages.length,
|
|
itemExtent: 72,
|
|
itemBuilder: (_, index) {
|
|
final package = packages[index];
|
|
return PackageListItem(
|
|
key: Key(package.packageName),
|
|
package: package,
|
|
value: valueList
|
|
.contains(package.packageName),
|
|
isActive: accessControl.enable,
|
|
onChanged: (value) {
|
|
_handleSelected(
|
|
valueList, package, value);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class PackageListItem extends StatelessWidget {
|
|
final Package package;
|
|
final bool value;
|
|
final bool isActive;
|
|
final void Function(bool?) onChanged;
|
|
|
|
const PackageListItem({
|
|
super.key,
|
|
required this.package,
|
|
required this.value,
|
|
required this.isActive,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FadeScaleEnterBox(
|
|
child: ActivateBox(
|
|
active: isActive,
|
|
child: ListItem.checkbox(
|
|
leading: SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: FutureBuilder<ImageProvider?>(
|
|
future: app?.getPackageIcon(package.packageName),
|
|
builder: (_, snapshot) {
|
|
if (!snapshot.hasData && snapshot.data == null) {
|
|
return Container();
|
|
} else {
|
|
return Image(
|
|
image: snapshot.data!,
|
|
gaplessPlayback: true,
|
|
width: 48,
|
|
height: 48,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
title: Text(
|
|
package.label,
|
|
style: const TextStyle(
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
maxLines: 1,
|
|
),
|
|
subtitle: Text(
|
|
package.packageName,
|
|
style: const TextStyle(
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
maxLines: 1,
|
|
),
|
|
delegate: CheckboxDelegate(
|
|
value: value,
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AccessControlSearchDelegate extends SearchDelegate {
|
|
List<String> acceptList = [];
|
|
List<String> rejectList = [];
|
|
|
|
AccessControlSearchDelegate({
|
|
required this.acceptList,
|
|
required this.rejectList,
|
|
});
|
|
|
|
@override
|
|
List<Widget>? buildActions(BuildContext context) {
|
|
return [
|
|
IconButton(
|
|
onPressed: () {
|
|
if (query.isEmpty) {
|
|
close(context, null);
|
|
return;
|
|
}
|
|
query = '';
|
|
},
|
|
icon: const Icon(Icons.clear),
|
|
),
|
|
const SizedBox(
|
|
width: 8,
|
|
)
|
|
];
|
|
}
|
|
|
|
@override
|
|
Widget? buildLeading(BuildContext context) {
|
|
return IconButton(
|
|
onPressed: () {
|
|
close(context, null);
|
|
},
|
|
icon: const Icon(Icons.arrow_back),
|
|
);
|
|
}
|
|
|
|
_handleSelected(
|
|
WidgetRef ref, List<String> valueList, Package package, bool? value) {
|
|
if (value == true) {
|
|
valueList.add(package.packageName);
|
|
} else {
|
|
valueList.remove(package.packageName);
|
|
}
|
|
ref.read(vpnSettingProvider.notifier).updateState((state) {
|
|
return switch (
|
|
state.accessControl.mode == AccessControlMode.acceptSelected) {
|
|
true => state.copyWith.accessControl(
|
|
acceptList: valueList,
|
|
),
|
|
false => state.copyWith.accessControl(
|
|
rejectList: valueList,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
Widget _packageList() {
|
|
final lowQuery = query.toLowerCase();
|
|
return Consumer(
|
|
builder: (context, ref, __) {
|
|
final vm3 = ref.watch(
|
|
packageListSelectorStateProvider.select(
|
|
(state) => VM3(
|
|
a: state.getSortList(
|
|
state.accessControl.mode == AccessControlMode.acceptSelected
|
|
? acceptList
|
|
: rejectList,
|
|
),
|
|
b: state.accessControl.enable,
|
|
c: state.accessControl.currentList,
|
|
),
|
|
),
|
|
);
|
|
final packages = vm3.a;
|
|
final queryPackages = packages
|
|
.where(
|
|
(package) =>
|
|
package.label.toLowerCase().contains(lowQuery) ||
|
|
package.packageName.contains(lowQuery),
|
|
)
|
|
.toList();
|
|
final isAccessControl = vm3.b;
|
|
final currentList = vm3.c;
|
|
final packageNameList = packages.map((e) => e.packageName).toList();
|
|
final valueList = currentList.intersection(packageNameList);
|
|
return DisabledMask(
|
|
status: !isAccessControl,
|
|
child: ListView.builder(
|
|
itemCount: queryPackages.length,
|
|
itemBuilder: (_, index) {
|
|
final package = queryPackages[index];
|
|
return PackageListItem(
|
|
key: Key(package.packageName),
|
|
package: package,
|
|
value: valueList.contains(package.packageName),
|
|
isActive: isAccessControl,
|
|
onChanged: (value) {
|
|
_handleSelected(
|
|
ref,
|
|
valueList,
|
|
package,
|
|
value,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget buildResults(BuildContext context) {
|
|
return buildSuggestions(context);
|
|
}
|
|
|
|
@override
|
|
Widget buildSuggestions(BuildContext context) {
|
|
return _packageList();
|
|
}
|
|
}
|
|
|
|
class AccessControlPanel extends ConsumerStatefulWidget {
|
|
const AccessControlPanel({
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
ConsumerState createState() => _AccessControlPanelState();
|
|
}
|
|
|
|
class _AccessControlPanelState extends ConsumerState<AccessControlPanel> {
|
|
IconData _getIconWithAccessControlMode(AccessControlMode mode) {
|
|
return switch (mode) {
|
|
AccessControlMode.acceptSelected => Icons.adjust_outlined,
|
|
AccessControlMode.rejectSelected => Icons.block_outlined,
|
|
};
|
|
}
|
|
|
|
String _getTextWithAccessControlMode(AccessControlMode mode) {
|
|
return switch (mode) {
|
|
AccessControlMode.acceptSelected => appLocalizations.whitelistMode,
|
|
AccessControlMode.rejectSelected => appLocalizations.blacklistMode,
|
|
};
|
|
}
|
|
|
|
String _getTextWithAccessSortType(AccessSortType type) {
|
|
return switch (type) {
|
|
AccessSortType.none => appLocalizations.defaultText,
|
|
AccessSortType.name => appLocalizations.name,
|
|
AccessSortType.time => appLocalizations.time,
|
|
};
|
|
}
|
|
|
|
IconData _getIconWithProxiesSortType(AccessSortType type) {
|
|
return switch (type) {
|
|
AccessSortType.none => Icons.sort,
|
|
AccessSortType.name => Icons.sort_by_alpha,
|
|
AccessSortType.time => Icons.timeline,
|
|
};
|
|
}
|
|
|
|
List<Widget> _buildModeSetting() {
|
|
return generateSection(
|
|
title: appLocalizations.mode,
|
|
items: [
|
|
SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
scrollDirection: Axis.horizontal,
|
|
child: Consumer(
|
|
builder: (_, ref, __) {
|
|
final accessControlMode = ref.watch(
|
|
vpnSettingProvider.select((state) => state.accessControl.mode),
|
|
);
|
|
return Wrap(
|
|
spacing: 16,
|
|
children: [
|
|
for (final item in AccessControlMode.values)
|
|
SettingInfoCard(
|
|
Info(
|
|
label: _getTextWithAccessControlMode(item),
|
|
iconData: _getIconWithAccessControlMode(item),
|
|
),
|
|
isSelected: accessControlMode == item,
|
|
onPressed: () {
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.accessControl(
|
|
mode: item,
|
|
),
|
|
);
|
|
},
|
|
)
|
|
],
|
|
);
|
|
},
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildSortSetting() {
|
|
return generateSection(
|
|
title: appLocalizations.sort,
|
|
items: [
|
|
SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
scrollDirection: Axis.horizontal,
|
|
child: Consumer(
|
|
builder: (_, ref, __) {
|
|
final accessSortType = ref.watch(
|
|
vpnSettingProvider.select((state) => state.accessControl.sort),
|
|
);
|
|
return Wrap(
|
|
spacing: 16,
|
|
children: [
|
|
for (final item in AccessSortType.values)
|
|
SettingInfoCard(
|
|
Info(
|
|
label: _getTextWithAccessSortType(item),
|
|
iconData: _getIconWithProxiesSortType(item),
|
|
),
|
|
isSelected: accessSortType == item,
|
|
onPressed: () {
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.accessControl(
|
|
sort: item,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildSourceSetting() {
|
|
return generateSection(
|
|
title: appLocalizations.source,
|
|
items: [
|
|
SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
scrollDirection: Axis.horizontal,
|
|
child: Consumer(
|
|
builder: (_, ref, __) {
|
|
final vm2 = ref.watch(
|
|
vpnSettingProvider.select(
|
|
(state) => VM2(
|
|
a: state.accessControl.isFilterSystemApp,
|
|
b: state.accessControl.isFilterNonInternetApp,
|
|
),
|
|
),
|
|
);
|
|
return Wrap(
|
|
spacing: 16,
|
|
children: [
|
|
SettingTextCard(
|
|
appLocalizations.systemApp,
|
|
isSelected: vm2.a == false,
|
|
onPressed: () {
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.accessControl(
|
|
isFilterSystemApp: !vm2.a,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
SettingTextCard(
|
|
appLocalizations.noNetworkApp,
|
|
isSelected: vm2.b == false,
|
|
onPressed: () {
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.accessControl(
|
|
isFilterNonInternetApp: !vm2.b,
|
|
),
|
|
);
|
|
},
|
|
)
|
|
],
|
|
);
|
|
},
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
_copyToClipboard() async {
|
|
await globalState.safeRun(() {
|
|
final data = globalState.config.vpnProps.accessControl.toJson();
|
|
Clipboard.setData(
|
|
ClipboardData(
|
|
text: json.encode(data),
|
|
),
|
|
);
|
|
});
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
_pasteToClipboard() async {
|
|
await globalState.safeRun(
|
|
() async {
|
|
final data = await Clipboard.getData('text/plain');
|
|
final text = data?.text;
|
|
if (text == null) return;
|
|
ref.read(vpnSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith(
|
|
accessControl: AccessControl.fromJson(
|
|
json.decode(text),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
List<Widget> _buildActionSetting() {
|
|
return generateSection(
|
|
title: appLocalizations.action,
|
|
items: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
),
|
|
child: Wrap(
|
|
runSpacing: 16,
|
|
spacing: 16,
|
|
children: [
|
|
CommonChip(
|
|
avatar: const Icon(Icons.auto_awesome),
|
|
label: appLocalizations.intelligentSelected,
|
|
onPressed: () {
|
|
Navigator.of(context).pop(1);
|
|
},
|
|
),
|
|
CommonChip(
|
|
avatar: const Icon(Icons.paste),
|
|
label: appLocalizations.clipboardImport,
|
|
onPressed: _pasteToClipboard,
|
|
),
|
|
CommonChip(
|
|
avatar: const Icon(Icons.content_copy),
|
|
label: appLocalizations.clipboardExport,
|
|
onPressed: _copyToClipboard,
|
|
)
|
|
],
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
..._buildModeSetting(),
|
|
..._buildSortSetting(),
|
|
..._buildSourceSetting(),
|
|
..._buildActionSetting(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|