Add linux deb dependencies Add backup recovery strategy select Support custom text scaling Optimize the display of different text scale Optimize windows setup experience Optimize startTun performance Optimize android tv experience Optimize default option Optimize computed text size Optimize hyperOS freeform window Add developer mode Update core Optimize more details
335 lines
8.3 KiB
Dart
335 lines
8.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:fl_clash/clash/clash.dart';
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:fl_clash/providers/config.dart';
|
|
import 'package:fl_clash/state.dart';
|
|
import 'package:fl_clash/widgets/widgets.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path/path.dart' hide context;
|
|
|
|
@immutable
|
|
class GeoItem {
|
|
final String label;
|
|
final String key;
|
|
final String fileName;
|
|
|
|
const GeoItem({
|
|
required this.label,
|
|
required this.key,
|
|
required this.fileName,
|
|
});
|
|
}
|
|
|
|
class Resources extends StatelessWidget {
|
|
const Resources({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const geoItems = <GeoItem>[
|
|
GeoItem(
|
|
label: "GeoIp",
|
|
fileName: geoIpFileName,
|
|
key: "geoip",
|
|
),
|
|
GeoItem(
|
|
label: "GeoSite",
|
|
fileName: geoSiteFileName,
|
|
key: "geosite",
|
|
),
|
|
GeoItem(
|
|
label: "MMDB",
|
|
fileName: mmdbFileName,
|
|
key: "mmdb",
|
|
),
|
|
GeoItem(
|
|
label: "ASN",
|
|
fileName: asnFileName,
|
|
key: "asn",
|
|
),
|
|
];
|
|
|
|
return ListView.separated(
|
|
itemBuilder: (_, index) {
|
|
final geoItem = geoItems[index];
|
|
return GeoDataListItem(
|
|
geoItem: geoItem,
|
|
);
|
|
},
|
|
separatorBuilder: (BuildContext context, int index) {
|
|
return const Divider(
|
|
height: 0,
|
|
);
|
|
},
|
|
itemCount: geoItems.length,
|
|
);
|
|
}
|
|
}
|
|
|
|
class GeoDataListItem extends StatefulWidget {
|
|
final GeoItem geoItem;
|
|
|
|
const GeoDataListItem({
|
|
super.key,
|
|
required this.geoItem,
|
|
});
|
|
|
|
@override
|
|
State<GeoDataListItem> createState() => _GeoDataListItemState();
|
|
}
|
|
|
|
class _GeoDataListItemState extends State<GeoDataListItem> {
|
|
final isUpdating = ValueNotifier<bool>(false);
|
|
|
|
GeoItem get geoItem => widget.geoItem;
|
|
|
|
_updateUrl(String url, WidgetRef ref) async {
|
|
final defaultMap = defaultGeoXUrl.toJson();
|
|
final newUrl = await globalState.showCommonDialog<String>(
|
|
child: UpdateGeoUrlFormDialog(
|
|
title: geoItem.label,
|
|
url: url,
|
|
defaultValue: defaultMap[geoItem.key],
|
|
),
|
|
);
|
|
if (newUrl != null && newUrl != url && mounted) {
|
|
try {
|
|
if (!newUrl.isUrl) {
|
|
throw "Invalid url";
|
|
}
|
|
ref.read(patchClashConfigProvider.notifier).updateState((state) {
|
|
final map = state.geoXUrl.toJson();
|
|
map[geoItem.key] = newUrl;
|
|
return state.copyWith(
|
|
geoXUrl: GeoXUrl.fromJson(map),
|
|
);
|
|
});
|
|
} catch (e) {
|
|
globalState.showMessage(
|
|
title: geoItem.label,
|
|
message: TextSpan(
|
|
text: e.toString(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<FileInfo> _getGeoFileLastModified(String fileName) async {
|
|
final homePath = await appPath.homeDirPath;
|
|
final file = File(join(homePath, fileName));
|
|
final lastModified = await file.lastModified();
|
|
final size = await file.length();
|
|
return FileInfo(
|
|
size: size,
|
|
lastModified: lastModified,
|
|
);
|
|
}
|
|
|
|
Widget _buildSubtitle() {
|
|
return Consumer(
|
|
builder: (_, ref, __) {
|
|
final url = ref.watch(
|
|
patchClashConfigProvider
|
|
.select((state) => state.geoXUrl.toJson()[geoItem.key]),
|
|
);
|
|
if (url == null) {
|
|
return SizedBox();
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(
|
|
height: 4,
|
|
),
|
|
FutureBuilder<FileInfo>(
|
|
future: _getGeoFileLastModified(geoItem.fileName),
|
|
builder: (_, snapshot) {
|
|
final height = globalState.measure.bodyMediumHeight;
|
|
return SizedBox(
|
|
height: height,
|
|
child: snapshot.data == null
|
|
? SizedBox(
|
|
width: height,
|
|
height: height,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: Text(
|
|
snapshot.data!.desc,
|
|
style: context.textTheme.bodyMedium,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Text(
|
|
url,
|
|
style: context.textTheme.bodyMedium?.toLight,
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
Wrap(
|
|
runSpacing: 6,
|
|
spacing: 12,
|
|
children: [
|
|
CommonChip(
|
|
avatar: const Icon(Icons.edit),
|
|
label: appLocalizations.edit,
|
|
onPressed: () {
|
|
_updateUrl(url, ref);
|
|
},
|
|
),
|
|
CommonChip(
|
|
avatar: const Icon(Icons.sync),
|
|
label: appLocalizations.sync,
|
|
onPressed: () {
|
|
_handleUpdateGeoDataItem();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
_handleUpdateGeoDataItem() async {
|
|
await globalState.safeRun<void>(
|
|
() async {
|
|
await updateGeoDateItem();
|
|
},
|
|
silence: false,
|
|
);
|
|
setState(() {});
|
|
}
|
|
|
|
updateGeoDateItem() async {
|
|
isUpdating.value = true;
|
|
try {
|
|
final message = await clashCore.updateGeoData(
|
|
UpdateGeoDataParams(
|
|
geoName: geoItem.fileName,
|
|
geoType: geoItem.label,
|
|
),
|
|
);
|
|
if (message.isNotEmpty) throw message;
|
|
} catch (e) {
|
|
isUpdating.value = false;
|
|
rethrow;
|
|
}
|
|
isUpdating.value = false;
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
isUpdating.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListItem(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 4,
|
|
),
|
|
title: Text(geoItem.label),
|
|
subtitle: _buildSubtitle(),
|
|
trailing: SizedBox(
|
|
height: 48,
|
|
width: 48,
|
|
child: ValueListenableBuilder(
|
|
valueListenable: isUpdating,
|
|
builder: (_, isUpdating, ___) {
|
|
return FadeThroughBox(
|
|
child: isUpdating
|
|
? const Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: CircularProgressIndicator(),
|
|
)
|
|
: const SizedBox(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UpdateGeoUrlFormDialog extends StatefulWidget {
|
|
final String title;
|
|
final String url;
|
|
final String? defaultValue;
|
|
|
|
const UpdateGeoUrlFormDialog(
|
|
{super.key, required this.title, required this.url, this.defaultValue});
|
|
|
|
@override
|
|
State<UpdateGeoUrlFormDialog> createState() => _UpdateGeoUrlFormDialogState();
|
|
}
|
|
|
|
class _UpdateGeoUrlFormDialogState extends State<UpdateGeoUrlFormDialog> {
|
|
late TextEditingController urlController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
urlController = TextEditingController(text: widget.url);
|
|
}
|
|
|
|
_handleReset() async {
|
|
if (widget.defaultValue == null) {
|
|
return;
|
|
}
|
|
Navigator.of(context).pop<String>(widget.defaultValue);
|
|
}
|
|
|
|
_handleUpdate() async {
|
|
final url = urlController.value.text;
|
|
if (url.isEmpty) return;
|
|
Navigator.of(context).pop<String>(url);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CommonDialog(
|
|
title: widget.title,
|
|
actions: [
|
|
if (widget.defaultValue != null &&
|
|
urlController.value.text != widget.defaultValue) ...[
|
|
TextButton(
|
|
onPressed: _handleReset,
|
|
child: Text(appLocalizations.reset),
|
|
),
|
|
const SizedBox(
|
|
width: 4,
|
|
),
|
|
],
|
|
TextButton(
|
|
onPressed: _handleUpdate,
|
|
child: Text(appLocalizations.submit),
|
|
)
|
|
],
|
|
child: Wrap(
|
|
runSpacing: 16,
|
|
children: [
|
|
TextField(
|
|
maxLines: 5,
|
|
minLines: 1,
|
|
controller: urlController,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|