feat(views): rework the proxies and scan pages, lay out records by their parts and unify search

A queued delay test shows a static clock until the task pool runs it, so
only the running tests animate while the list scrolls.

Changelog: Hide timed-out nodes and search every list
This commit is contained in:
chen08209
2026-09-25 03:57:04 +08:00
parent 841d6035d5
commit 8378c7081c
101 changed files with 15754 additions and 4997 deletions
+3 -1
View File
@@ -230,7 +230,9 @@ surface. It is shown only outside dashboard edit mode and only when `coreLib ==
Proxy delay testing follows the same failure-safe UI rule. `ProxiesAction._runDelayTests` marks its keys in
`pendingDelayTestsProvider` and releases them in `finally`, and a test the Core does not answer leaves the last
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A group's delay
measurement in place; the delay-test bullets under Core API Safety in `.agents/rules.md` give the reasons. A key stays
`queued` until the `TaskPool` actually runs it, so node cards animate a loader for at most `maxConcurrentDelayTests`
nodes and show a static glyph for the rest; a loader on every queued node made fast scrolling drop frames. A group's delay
test button, the tab page's action and the list page's group header alike, spins while `delayTestingGroupsProvider`
holds the group; `delayTestPageGroup` marks it, ignores a second request for the same group, and clears it in `finally`,
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
+234 -79
View File
@@ -9,19 +9,32 @@
"profiles": "Profiles",
"tools": "Tools",
"logs": "Logs",
"logsDesc": "Captured log records",
"resources": "Resources",
"resourcesDesc": "Information about external resources",
"trafficUsage": "Traffic usage",
"networkSpeed": "Network speed",
"outboundMode": "Outbound mode",
"networkDetection": "Network detection",
"serviceStatus": "Service status",
"serviceRestricted": "Access restricted",
"serviceAvailable": "Available",
"serviceUnavailable": "Unavailable",
"serviceFailed": "Check failed",
"serviceDisallowedIsp": "Disallowed ISP",
"serviceBlocked": "Blocked",
"serviceUnsupportedRegion": "Region not supported",
"serviceOriginalsOnly": "Originals only",
"serviceComingSoon": "Coming soon",
"servicePending": "Not checked",
"serviceManage": "Manage services",
"serviceCheck": "Check",
"serviceCheckAll": "Check all",
"serviceCheckedAt": "Checked at {time}",
"upload": "Upload",
"download": "Download",
"usedTraffic": "Used traffic",
"totalTraffic": "Total traffic",
"expireTime": "Expiration time",
"nullProfileDesc": "No profiles yet, please add one first",
"nullProfileDesc": "Add a profile to get started",
"settings": "Settings",
"language": "Language",
"defaultText": "Default",
@@ -36,27 +49,22 @@
"themeDesc": "Set dark mode and adjust colors",
"override": "Override",
"allowLan": "Allow LAN",
"allowLanDesc": "Allow proxy access over the LAN",
"tun": "TUN",
"tunDesc": "Only effective in administrator mode",
"minimizeOnExit": "Minimize on exit",
"minimizeOnExitDesc": "Override the default system exit behavior",
"autoLaunch": "Auto launch",
"autoLaunchDesc": "Launch automatically at system startup",
"silentLaunch": "Silent launch",
"silentLaunchDesc": "Start in the background",
"silentLaunchDesc": "Start without showing the window",
"autoRun": "Auto run",
"autoRunDesc": "Run automatically when the app opens",
"logcat": "Logcat",
"logcatDesc": "Disabling hides the log entry point",
"autoCheckUpdate": "Auto check for updates",
"autoCheckUpdateDesc": "Check for updates automatically when the app starts",
"checkCertificate": "Verify TLS certificates",
"checkCertificateDesc": "Reject untrusted certificates. Turning this off exposes subscriptions and backups to man-in-the-middle attacks",
"accessControl": "Access control",
"accessControlDesc": "Control which apps use the proxy",
"application": "Application",
"applicationDesc": "Adjust application settings",
"edit": "Edit",
"confirm": "Confirm",
"update": "Update",
@@ -103,12 +111,14 @@
"show": "Show",
"exit": "Exit",
"systemProxy": "System proxy",
"safeMode": "Safe mode",
"safeModeAppTitle": "{appName} (Safe mode)",
"project": "Project",
"core": "Core",
"tabAnimation": "Tab animation",
"desc": "A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.",
"startVpn": "Starting VPN...",
"stopVpn": "Stopping VPN...",
"startVpn": "Starting VPN…",
"stopVpn": "Stopping VPN…",
"compatible": "Compatibility mode",
"notSelectedTip": "The current proxy group cannot be selected",
"changeProxyFailedTip": "Failed to switch proxy; the previous selection has been restored",
@@ -137,28 +147,26 @@
"country": "Region",
"search": "Search",
"allowBypass": "Allow apps to bypass VPN",
"allowBypassDesc": "When enabled, some apps can bypass the VPN",
"externalController": "External controller",
"externalControllerDesc": "When enabled, the Clash core can be controlled on port 9090",
"ipv6Desc": "When enabled, IPv6 traffic can be received",
"app": "App",
"general": "General",
"systemProxyDesc": "Set the system proxy",
"startupAndBackground": "Startup and background",
"requestsAndUpdates": "Requests and updates",
"logsAndDiagnostics": "Logs and diagnostics",
"userAgent": "User-Agent",
"unifiedDelay": "Unified delay",
"unifiedDelayDesc": "Remove extra delays such as handshakes",
"tcpConcurrent": "TCP concurrent",
"tcpConcurrentDesc": "Allow concurrent TCP connections",
"geodataLoader": "Geo low-memory mode",
"geodataLoaderDesc": "Use the low-memory Geo loader",
"requests": "Requests",
"requestsDesc": "View recent request records",
"recentRequests": "Recent requests",
"findProcessMode": "Find process",
"init": "Init",
"infiniteTime": "Never expires",
"connections": "Connections",
"liveConnections": "Live connections",
"inbound": "Inbound",
"connectionsDesc": "View current connection data",
"intranetIP": "Intranet IP",
"view": "View",
"cut": "Cut",
@@ -182,19 +190,18 @@
"go": "Go",
"externalLink": "External link",
"otherContributors": "Other contributors",
"appIconDesign": "App icon design",
"autoCloseConnections": "Auto close connections",
"autoCloseConnectionsDesc": "Close connections automatically after switching nodes",
"onlyStatisticsProxy": "Only count proxy traffic",
"onlyStatisticsProxyDesc": "When enabled, only proxy traffic is counted",
"showNotificationStopAction": "Stop button in notification",
"showNotificationStopActionDesc": "Show a stop button on the persistent notification. Turn it off if your system keeps the notification expanded because of it",
"pureBlackMode": "Pure black mode",
"sidebarBlur": "Sidebar blur",
"sidebarBlurDesc": "Show the blurred desktop behind the window through the sidebar",
"keepAliveIntervalDesc": "TCP keep-alive interval",
"entries": " entries",
"local": "Local",
"remote": "Remote",
"remoteBackupDesc": "Back up data to WebDAV",
"localBackupDesc": "Back up data locally",
"mode": "Mode",
"time": "Time",
"source": "Source",
@@ -209,69 +216,119 @@
"profilesSort": "Sort profiles",
"start": "Start",
"stop": "Stop",
"dnsDesc": "Update DNS-related settings",
"key": "Key",
"value": "Value",
"hostsDesc": "Append hosts",
"vpnTip": "Changes take effect after restarting the VPN",
"vpnEnableDesc": "Route all system traffic through VpnService automatically",
"options": "Options",
"loopback": "Loopback unlock tool",
"loopbackDesc": "Used for UWP loopback exemption",
"enabled": "Enabled",
"disabled": "Disabled",
"loopback": "UWP loopback exemption",
"providers": "External resources",
"proxyProviders": "Proxy providers",
"ruleProviders": "Rule providers",
"appProxyProviders": "App proxy providers",
"appRuleProviders": "App rule providers",
"behavior": "Behavior",
"format": "Format",
"providerUrlTip": "Only remote providers are supported",
"subscriptionInfo": "Subscription info",
"overrideDns": "Override DNS",
"overrideDnsDesc": "When enabled, the DNS options in the profile are overridden",
"overrideNtp": "Override NTP",
"ntpStatusDesc": "Take the time from an NTP server instead of the system clock",
"server": "Server",
"ntpInterval": "Sync interval (minutes)",
"dialerProxy": "Dialer proxy",
"dialerProxyDesc": "The outbound used to reach the NTP server",
"writeToSystem": "Write to system",
"writeToSystemDesc": "Also set the system clock; Android ignores it",
"overrideEntries": "Override entries",
"addOverrideEntry": "Add override entry",
"discardChanges": "Discard the changes?",
"quickEdit": "Quick edit",
"status": "Status",
"statusDesc": "When disabled, the system DNS is used",
"preferH3Desc": "Prefer HTTP/3 for DoH",
"respectRules": "Respect rules",
"respectRulesDesc": "DNS connections follow rules; requires proxy-server-nameserver",
"respectRulesDesc": "DNS connections follow rules; requires Proxy Server Nameserver",
"dnsMode": "DNS mode",
"fakeipRange": "Fake-IP range",
"fakeipFilter": "Fake-IP filter",
"defaultNameserver": "Default nameserver",
"defaultNameserverDesc": "Used to resolve DNS servers",
"nameserver": "Nameserver",
"nameserverDesc": "Used to resolve domains",
"useHosts": "Use hosts",
"useSystemHosts": "Use system hosts",
"nameserverPolicy": "Nameserver policy",
"nameserverPolicyDesc": "Specify the nameserver policy for matching domains",
"proxyNameserver": "Proxy nameserver",
"proxyNameserverDesc": "Used to resolve proxy node domains",
"fallback": "Fallback",
"fallbackDesc": "Usually an overseas DNS",
"fallbackFilter": "Fallback filter",
"geoipCode": "GeoIP code",
"ipcidr": "IP/CIDR",
"domain": "Domain",
"listenRoutingMark": "Listen routing mark",
"listenRoutingMarkDesc": "Linux only",
"ipv6Timeout": "IPv6 timeout (ms)",
"cacheAlgorithm": "Cache algorithm",
"cacheMaxSize": "Cache size",
"fakeipRange6": "Fake-IP range (IPv6)",
"fakeipFilterMode": "Fake-IP filter mode",
"fakeipFilterModeDesc": "blacklist excludes matches, whitelist fakes only matches, rule matches as rules",
"fakeipTtl": "Fake-IP TTL",
"reset": "Reset",
"actionView": "Show/Hide",
"actionStart": "Start/Stop",
"actionMode": "Switch mode",
"actionProxy": "System proxy",
"actionTun": "TUN",
"actionRuleMode": "Rule mode",
"actionGlobalMode": "Global mode",
"actionDirectMode": "Direct mode",
"actionDelayTest": "Test all delays",
"actionUpdateProfiles": "Update profiles",
"disclaimer": "Disclaimer",
"disclaimerDesc": "This software is intended only for non-commercial uses such as learning and research. Using it for any commercial purpose is strictly prohibited; any commercial activity is unrelated to this software.",
"disclaimerDesc": "Before using FlClash (\"the Software\"), please read this statement carefully and make sure you understand all of it. Tapping \"Agree\" means you have read, understood, and accept every term below. If you do not agree, tap \"Exit\" and stop using the Software.",
"disclaimerSoftwareTitle": "Nature of the software",
"disclaimerSoftwareContent": "The Software is an open-source network proxy client built on the Clash.Meta (mihomo) core. It only provides local tooling such as configuration management, rule-based routing, and traffic forwarding.\n\nThe Software itself does not provide any proxy server, node, subscription, or network access service, and has no partnership, agency, or guarantee relationship with any provider of such services.",
"disclaimerUsageTitle": "Restrictions on use",
"disclaimerUsageContent": "The Software is intended only for non-commercial uses such as learning, exchange, and technical research. Using it for any commercial purpose is strictly prohibited, including but not limited to paid distribution, bundled sales, use as part of a commercial service, or doing business in the name of the Software. Any commercial activity is unrelated to the Software and its developers.\n\nUsing the Software for anything that violates the laws and regulations of your country or region is strictly prohibited, including but not limited to bypassing lawfully imposed network access restrictions, spreading illegal content, launching network attacks, or infringing the lawful rights of others.",
"disclaimerResponsibilityTitle": "Your responsibility",
"disclaimerResponsibilityContent": "You are responsible for making sure that using the Software is lawful where you live, and you alone bear the legal responsibility for everything you do with it and its consequences.\n\nThe subscriptions, nodes, and configurations you import are your own choice. Whether their source is lawful, their content is safe, and their service is reliable is a matter between you and their providers.",
"disclaimerThirdPartyTitle": "Third-party content",
"disclaimerThirdPartyContent": "Subscription links, configuration files, rule sets, scripts, external resources, and external links are all provided by third parties. The developers cannot and do not review or guarantee their legality, accuracy, security, or availability.\n\nAny data leak, financial loss, account ban, or other loss caused by third-party content is to be settled between you and the third party; the developers bear no responsibility for it.",
"disclaimerWarrantyTitle": "No warranty",
"disclaimerWarrantyContent": "The Software is provided \"as is\" and \"as available\", without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, uninterrupted availability, freedom from errors, or freedom from security vulnerabilities.\n\nThe developers do not guarantee that the Software will meet your needs or that it will run without interruption or error.",
"disclaimerLiabilityTitle": "Limitation of liability",
"disclaimerLiabilityContent": "To the maximum extent permitted by applicable law, neither the developers nor any contributor shall be liable for any direct, indirect, incidental, special, punitive, or consequential damages arising from the use of or inability to use the Software, including but not limited to data loss, device damage, network failures, business interruption, lost profits, or any resulting legal dispute, even if advised of the possibility of such damages.",
"disclaimerPrivacyTitle": "Data collection and privacy",
"disclaimerPrivacyContent": "The Software does not collect or upload your subscription URLs, node details, configuration content, visited websites, connection records, traffic content, or logs. This data stays on your device and the developers have no access to it.\n\nThe Software only reaches the network when you use a feature that needs it, such as fetching the subscription URL you provided when updating a profile, or contacting GitHub when checking for updates.\n\nThe desktop versions (Windows, macOS, Linux) include no analytics or crash reporting service. The Android version includes the following two Google Firebase services to improve stability:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics (crash analytics)",
"disclaimerCrashlyticsContent": "When the app crashes, a crash report is uploaded automatically.\n\nWhat is collected: the crash stack trace and error message, the time of the crash, the app version and build number, the device brand and model, the Android version, screen orientation, free memory and storage, whether the device is rooted, and a random installation ID that is created on install and reset on reinstall.\n\nPurpose: only to locate and fix crashes.\n\nYou can turn it off at any time under \"Tools > General > Crash analytics\".",
"disclaimerAnalyticsTitle": "Firebase Analytics (usage statistics)",
"disclaimerAnalyticsContent": "Basic app usage statistics are collected automatically with Firebase.\n\nWhat is collected: basic events such as first launch, app opens and session length, and app updates; an app instance ID; the device model, OS version, and system language; and an approximate country- or region-level location inferred from the IP address.\n\nPurpose: only to understand the number of active devices, version distribution, and OS compatibility. The developers do not use this data for advertising, do not sell it, and do not link it to your subscriptions or configurations.",
"disclaimerDataProcessingContent": "This data is processed and stored by Google on our behalf, may be transferred to servers outside your country or region (such as in the United States), and is governed by the Google Privacy Policy and the Firebase privacy and security documentation. Crash reports are kept for up to 90 days; statistics are kept under the Firebase default retention policy.",
"disclaimerLicenseTitle": "Open-source license",
"disclaimerLicenseContent": "The Software is open source under the GPL-3.0 license. You may use, modify, and distribute it freely as long as you comply with that license, which requires derivative works to be released under GPL-3.0 as well and the original copyright notices to be kept.\n\nThird-party components in the Software, including the Clash.Meta core, follow their own licenses. The original authors are not responsible for any issue arising from modified or redistributed versions.",
"disclaimerChangesTitle": "Changes to this statement",
"disclaimerChangesContent": "The developers may revise this statement with any release, and the revision takes effect when that release is published. Continuing to use the Software after updating means you accept the revised statement.",
"disclaimerAcceptTitle": "Acceptance",
"disclaimerAcceptContent": "By installing, copying, or using the Software, you are deemed to have read and agreed to this entire statement. If you disagree with any of its terms, stop using and uninstall the Software immediately.",
"disclaimerAndroidOnly": "Android only",
"disclaimerGooglePrivacy": "Google Privacy Policy",
"disclaimerFirebasePrivacy": "Firebase privacy and security",
"agree": "Agree",
"hotkeyManagement": "Hotkey management",
"hotkeyManagementDesc": "Control the app with the keyboard",
"pressKeyboard": "Please press a key",
"inputCorrectHotkey": "Please enter a valid hotkey",
"hotkeyConflict": "Hotkey conflict",
"hotkeyDesc": "Global hotkeys work even while the window is hidden. Tap an action to record its key combination.",
"hotkeyNotSet": "Not set",
"pressKeyboard": "Press a key combination",
"hotkeyNeedsModifier": "Include at least one of {modifiers}",
"hotkeyConflictWith": "Already used by “{action}”. Saving moves it here.",
"hotkeyUnavailable": "Not registered, it may be taken by another app",
"remove": "Remove",
"noHotKey": "No hotkeys yet",
"noNetwork": "No network",
"ipv6InboundDesc": "Allow IPv6 inbound",
"exportLogs": "Export logs",
"exportSuccess": "Export successful",
"iconStyle": "Icon style",
"onlyIcon": "Icon only",
"iconStyleFilled": "Filled",
"iconStylePlain": "Plain",
"iconStyleHidden": "Hidden",
"filter": "Filter",
"hideTimeoutProxies": "Hide timed-out nodes",
"hideTimeoutProxiesDesc": "Leave out nodes whose last delay test timed out",
"stackMode": "Stack mode",
"network": "Network",
"networkDesc": "Adjust network-related settings",
"bypassDomain": "Bypass domains",
"bypassDomainDesc": "Only takes effect while the system proxy is enabled",
"resetTip": "Are you sure you want to reset?",
@@ -284,7 +341,6 @@
"routeModeBypassPrivate": "Bypass private addresses",
"routeModeConfig": "Use config",
"routeAddress": "Route addresses",
"routeAddressDesc": "Configure the listened route addresses",
"interfaceNameMode": "Outbound interface",
"interfaceNameModeClear": "Clear",
"interfaceNameModeFollow": "Follow config",
@@ -293,6 +349,20 @@
"interfaceNameDesc": "Network interface used for outbound connections",
"copyEnvVar": "Copy environment variables",
"memoryInfo": "Memory info",
"total": "Total",
"releaseMemory": "Release memory",
"memoryCoreHeapInuse": "Heap in use",
"memoryCoreHeapIdle": "Heap idle",
"memoryCoreStack": "Goroutine stacks",
"memoryCoreRuntime": "Runtime overhead",
"memoryCoreNotRunning": "Core is not running",
"memoryAppResident": "Resident memory",
"memoryAppShared": "App & shared",
"memoryEstimateDesc": "Estimated from process resident memory; it may differ from what the system reports.",
"memoryEstimateSharedDesc": "The Core runs inside the app process. Its share is estimated from runtime stats, and the rest counts as app and shared memory.",
"memoryReleased": "Memory released",
"memoryReleasedSize": "Released {size}",
"releaseMemoryFailed": "Failed to release memory",
"cancel": "Cancel",
"fileIsUpdate": "The file has been modified. Save the changes?",
"profileHasUpdate": "The profile has been modified. Turn off auto update?",
@@ -305,27 +375,27 @@
"listen": "Listen",
"undo": "Undo",
"redo": "Redo",
"lineWrap": "Word wrap",
"none": "None",
"basicConfig": "Basic configuration",
"basicConfigDesc": "Modify the basic configuration globally",
"advancedConfig": "Advanced configuration",
"advancedConfigDesc": "Provides diverse configuration options",
"advancedConfigDesc": "Network, DNS, added rules, and scripts",
"selectedCountTitle": "{count} selected",
"addRule": "Add rule",
"ruleName": "Rule name",
"content": "Content",
"subRule": "Sub-rule",
"ruleTarget": "Rule target",
"matchTargetTitle": "Match target",
"matchTarget": "MATCH-TARGET",
"followProfile": "Follow profile",
"matchTargetDesc": "Where rules targeting MATCH-TARGET go. Defaults to the target of the final MATCH rule in this profile.",
"selectMatchTarget": "Select MATCH-TARGET",
"sourceIp": "Source IP",
"noResolve": "Don't resolve IP",
"saveChanges": "Save the changes?",
"findProcessModeDesc": "Enabling causes some performance loss",
"tabAnimationDesc": "Only effective in mobile view",
"navigationBarStyle": "Bottom bar",
"floating": "Floating",
"docked": "Docked",
"slide": "Slide",
"fade": "Fade",
"pureBlack": "Pure black",
"colorSchemes": "Color schemes",
"palette": "Palette",
"tonalSpotScheme": "Tonal spot",
@@ -348,6 +418,8 @@
"launchInterruptedTip": "The app exited unexpectedly while it was starting up last time. Automatic setup was skipped for this launch; you can start it manually to retry.",
"clearData": "Clear data",
"textScale": "Text scaling",
"textScalePreview": "Text in the app will look like this",
"followSystem": "Follow system",
"internet": "Internet",
"systemApp": "System apps",
"noNetworkApp": "No-network apps",
@@ -361,7 +433,13 @@
"interval": "Interval",
"existsTip": "{label} already exists",
"maxLengthTip": "{label} must be at most {max} characters",
"multipleValuesTip": "Separate multiple values with commas",
"singleAdd": "Single add",
"batchAdd": "Batch add",
"batchListInputTip": "One item per line, or separated by commas",
"batchMapInputTip": "One entry per line: key, a space, then value",
"batchPreviewTip": "{count} to add, {skipped} skipped as existing",
"lineIssueTip": "Line {line}: {message}",
"singleValueTip": "{label} must be a single item",
"deleteTip": "Are you sure you want to delete this {label}?",
"deleteMultipTip": "Are you sure you want to delete the selected {label}?",
"nullTip": "No {label} yet",
@@ -378,6 +456,7 @@
"import": "Import",
"importFile": "Import from file",
"importUrl": "Import from URL",
"startFromScratch": "Start from scratch",
"autoSetSystemDns": "Auto-set system DNS",
"details": "{label} details",
"creationTime": "Creation time",
@@ -396,7 +475,7 @@
"request": "Request",
"connected": "Connected",
"disconnected": "Disconnected",
"connecting": "Connecting...",
"connecting": "Connecting…",
"restartCoreTip": "Are you sure you want to restart the core?",
"forceRestartCoreTip": "Are you sure you want to force restart the core?",
"dnsHijacking": "DNS hijacking",
@@ -406,20 +485,25 @@
"crashlytics": "Crash analytics",
"crashlyticsTip": "When enabled, crash logs without sensitive information are uploaded automatically when the app crashes",
"appendSystemDns": "Append system DNS",
"appendSystemDnsTip": "Force-append the system DNS to the configuration",
"editRule": "Edit rule",
"overrideMode": "Override mode",
"standardModeDesc": "Standard mode: overrides the basic configuration and offers simple rule additions",
"scriptModeDesc": "Script mode: uses external extension scripts to override the configuration in one click",
"addedRules": "Added rules",
"controlGlobalAddedRules": "Control global added rules",
"quickAdd": "Quick add",
"rulePresetBlockQuic": "Block QUIC",
"rulePresetBlockStun": "Block STUN",
"rulePresetBlockDot": "Block DNS over TLS",
"rulePresetSystemServicesDirect": "Apple and Microsoft direct",
"rulePresetBittorrentDirect": "BitTorrent direct",
"rulePresetLanDirect": "LAN direct",
"overrideScript": "Override script",
"goToConfigureScript": "Go to script configuration",
"editGlobalRules": "Edit global rules",
"externalFetch": "External fetch",
"confirmForceCrashCore": "Are you sure you want to force crash the core?",
"confirmClearAllData": "Are you sure you want to clear all data?",
"loading": "Loading...",
"loading": "Loading…",
"yearsAgo": "{count, plural, =1{1 year ago} other{{count} years ago}}",
"monthsAgo": "{count, plural, =1{1 month ago} other{{count} months ago}}",
"daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
@@ -435,24 +519,31 @@
"speedStatistics": "Speed statistics",
"resetPageChangesTip": "This page has changes. Are you sure you want to reset?",
"overwriteTypeCustom": "Custom",
"overwriteTypeCustomDesc": "Custom mode: fully customize proxy groups and rules",
"overwriteTypeCustomDesc": "Custom mode: fully customize proxies, proxy groups and rules",
"unknownNetworkError": "Unknown network error",
"restoreException": "Restore error",
"networkException": "Network error, please check your connection and try again",
"backupFromNewerVersion": "This backup comes from a newer version of the app. Update the app before restoring it",
"networkTimeoutError": "The request timed out. Check your network or proxy, then try again",
"networkHostLookupError": "Couldn't resolve the server address. Check that the URL is correct and DNS is working",
"networkConnectionError": "Couldn't connect to the server. Check your network connection or proxy settings",
"networkTlsError": "Secure connection failed. The server's certificate may be invalid, or the connection is being intercepted",
"networkCancelledError": "The request was cancelled",
"networkAccessDeniedError": "The server denied access (HTTP {code}). The link may have expired, or the credentials are wrong",
"networkNotFoundError": "Nothing was found at this address (HTTP {code}). Check that the URL is correct",
"networkRateLimitedError": "Too many requests (HTTP 429). Wait a moment and try again",
"networkServerError": "The server ran into a problem (HTTP {code}). Try again later",
"networkBadResponseError": "The server rejected the request (HTTP {code})",
"networkRequestFailed": "Network request failed: {detail}",
"invalidBackupFile": "Invalid backup file",
"pruneCache": "Prune cache",
"backupAndRestore": "Backup and restore",
"backupAndRestoreDesc": "Sync data via WebDAV or files",
"restore": "Restore",
"restoreSuccess": "Restore successful",
"restoreFromWebDAVDesc": "Restore data from WebDAV",
"restoreFromFileDesc": "Restore data from a file",
"restoreOnlyConfig": "Restore profiles only",
"restoreAllData": "Restore all data",
"addProfile": "Add profile",
"delayTest": "Delay test",
"proxyGroupEmpty": "Proxy group is empty",
"proxyGroupNameEmpty": "Proxy group name cannot be empty",
"proxyGroupNameDuplicate": "Duplicate proxy group name",
"confirmExitWindow": "Are you sure you want to exit the current window?",
"dataChangedSave": "Data changes detected. Save them?",
@@ -464,6 +555,8 @@
"excludeProxyFilter": "Exclude proxy filter",
"excludeType": "Exclude type",
"expectedStatus": "Expected status",
"strategy": "Strategy",
"tolerance": "Tolerance",
"selectProxies": "Select proxies",
"inputProxyGroupName": "Enter the proxy group name",
"helperCorruptTip": "Helper service unavailable; TUN mode cannot be enabled. Reinstall FlClash to restore it.",
@@ -488,7 +581,7 @@
"basicStrategy": "Basic strategies",
"editProxy": "Edit proxy",
"includeAllProxyProviders": "Include all proxy providers",
"includeAllProxyProvidersTip": "When enabled, the imported proxy providers are overridden",
"includeAllProxyProvidersTip": "When enabled, the group takes every proxy provider of this profile: the subscription's own, plus the profiles and app proxy providers any proxy group uses",
"addProxyProviders": "Add proxy providers",
"includeAllProxies": "Include all proxies",
"includeAllProxiesTip": "Imports all proxies outside proxy groups; extra proxy groups can be added below",
@@ -550,17 +643,16 @@
"invalidSubRule": "{subRule} is an invalid SUB_RULE",
"invalidPolicy": "{target} is an invalid policy",
"invalidProxyProvider": "{providerName} is an invalid proxy provider",
"invalidRuleSet": "{ruleSet} is an invalid rule set",
"invalidNetworkContent": "Only tcp or udp is supported",
"invalidRangeContent": "Enter numbers or ranges such as 80 or 8000-9000, separated by /",
"invalidDscpContent": "A DSCP mark cannot exceed 63",
"invalidProxy": "{proxyName} is an invalid proxy",
"proxyGroupDetectedAbnormal": "The current proxy group is abnormal",
"proxyProviderDetectedAbnormal": "The selected proxy providers are abnormal",
"proxyDetectedAbnormal": "The selected proxies are abnormal",
"createProfile": "Create profile",
"locationPermissionRequired": "Location permission required",
"locationPermissionGuide": "1. Open System Settings > Privacy & Security\n2. Choose Location Services\n3. Find and check {appName} in the list\n\nWhen you are done, return to the app to continue. Thank you for your cooperation.",
"prerequisites": "Prerequisites",
"ignoreBatteryOptimization": "Ignore battery optimization",
"batteryOptimizationDesc": "To keep the app running in the background, disable battery optimization for it. Tap to open settings.",
"batteryOptimizationStatusTip": "Due to system limitations, the battery optimization status cannot be read correctly while running",
"locationPermission": "Location permission",
"locationPermissionDesc": "The system requires location permission to read the Wi-Fi name. On Android choose \"Allow all the time\", otherwise the Wi-Fi name cannot be read while the app is in the background.",
"excludeSsids": "Exclude SSIDs",
@@ -573,7 +665,7 @@
"editSsid": "Edit SSID",
"authorized": "Authorized",
"tapToAuthorize": "Tap to authorize",
"suspended": "Suspended...",
"suspended": "Suspended…",
"geoOptions": "Geo options",
"geoAutoUpdate": "Auto update",
"geoAutoUpdateInterval": "Auto-update interval",
@@ -584,7 +676,6 @@
"geoSkipped": "{name} is already up to date",
"geoUpdated": "{name} updated",
"secondsCount": "{count, plural, =1{1 second} other{{count} seconds}}",
"entriesCount": "{count, plural, =1{1 entry} other{{count} entries}}",
"proxiesCount": "{count, plural, =1{1 proxy} other{{count} proxies}}",
"rulesCount": "{count, plural, =1{1 rule} other{{count} rules}}",
"changelogBreaking": "Breaking changes",
@@ -600,7 +691,6 @@
"pinWindow": "Pin window",
"unpinWindow": "Unpin window",
"exitFullScreen": "Exit full screen",
"toggleLabel": "Toggle labels",
"torch": "Flashlight",
"pickFromAlbum": "Choose from album",
"blockConnection": "Block connection",
@@ -611,11 +701,76 @@
"previousMatch": "Previous match",
"nextMatch": "Next match",
"clearSearch": "Clear search",
"noSearchResults": "No matching results",
"addWidget": "Add widget",
"showPassword": "Show password",
"hidePassword": "Hide password",
"authorize": "Authorize",
"installedAppsPermissionRequired": "App list permission required",
"installedAppsPermissionDesc": "This system hides the installed app list until the permission is granted. Authorize it to configure the per-app proxy.",
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings."
"installedAppsPermissionDeniedMessage": "The app list permission was denied, so installed apps cannot be listed. Please grant it manually in system settings.",
"outboundIp": "Outbound IP",
"ipAddress": "IP address",
"ipQualityLevel": "Level",
"ipQualityGood": "Good",
"ipQualityNormal": "Normal",
"ipQualityRisky": "Risky",
"ipType": "Type",
"ipTypeResidential": "Residential",
"ipTypeMobile": "Mobile network",
"ipTypeBusiness": "Business",
"ipTypeHosting": "Data center",
"ipFlags": "Flags",
"ipFlagProxy": "Proxy",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "Abuse history",
"ipOrganization": "Organization",
"ipAsn": "ASN",
"ipQualitySource": "Answered by",
"ipQualitySources": "Sources",
"ipQualityFailed": "Couldn't determine the IP type",
"ipQualityRetry": "Check again",
"ipSourceNoType": "No type",
"ipSourceRateLimited": "Rate limited",
"ipSourceIpMismatch": "Different outbound IP",
"hideIp": "Hide IP",
"editorUnavailable": "Editor unavailable",
"replace": "Replace",
"replaceAll": "Replace all",
"cameraPermissionRequired": "Camera permission required",
"cameraPermissionDesc": "Allow camera access in system settings to scan QR codes, or choose a QR code image from the album.",
"cameraUnavailable": "Camera unavailable",
"retry": "Retry",
"invalidProfileQrcode": "This QR code doesn't contain a profile link",
"dnsQueries": "DNS queries",
"recordType": "Record type",
"answers": "Answers",
"responseCode": "Response code",
"error": "Error",
"initiator": "Initiator",
"cache": "Cache",
"proxyNode": "Proxy node",
"yes": "Yes",
"no": "No",
"nonTextProviderFile": "This external resource is not a text file",
"addCustomProxy": "Add proxy",
"proxyDefinition": "Full configuration",
"proxyDefinitionNotMap": "The configuration must be a YAML mapping with a name and a type",
"customProxiesEmpty": "No custom proxies, so the profile's own proxies are used",
"overwriteIssueEmptyName": "The name is empty",
"overwriteIssueReservedName": "{name} is a built-in policy name and cannot be used here",
"overwriteIssueDuplicateName": "The name {name} is already used by another proxy or proxy group",
"overwriteIssueCoreRejected": "The core cannot parse this proxy: {message}",
"overwriteIssueMissingProxies": "These proxies or policies do not exist: {names}",
"overwriteIssueMissingProviders": "These proxy providers do not exist: {names}",
"overwriteIssueNoProxySource": "No proxies or proxy providers are selected, so the core rejects this group",
"overwriteIssueGroupLoop": "Proxy groups reference each other in a loop: {path}",
"overwriteIssuesSummary": "{count} items have problems, and applying this override may fail",
"overwriteIssueSubscriptionGroupMissingProxies": "The profile's own proxy groups name proxies that the custom proxies no longer include: {names}",
"overwriteIssueProviderShadowed": "The profile already has a provider named {name}, so the app-level one is not used. Rename it to use it",
"providerInUse": "{label} is still used by the custom proxy groups or rules of {profiles}. Remove it there first",
"providerSourceSubscription": "Subscription",
"providerRenameShadowed": "The subscriptions of {profiles} already have {label}, so those profiles would switch to theirs. Choose another name",
"appProviderShadowed": "The app-level one of this name is not used here"
}
+235 -80
View File
@@ -9,19 +9,32 @@
"profiles": "プロファイル",
"tools": "ツール",
"logs": "ログ",
"logsDesc": "キャプチャしたログの記録",
"resources": "リソース",
"resourcesDesc": "外部リソースの関連情報",
"trafficUsage": "トラフィック統計",
"networkSpeed": "ネットワーク速度",
"outboundMode": "アウトバウンドモード",
"networkDetection": "ネットワーク検出",
"serviceStatus": "サービスの状態",
"serviceRestricted": "アクセス制限",
"serviceAvailable": "利用可能",
"serviceUnavailable": "利用不可",
"serviceFailed": "検出に失敗しました",
"serviceDisallowedIsp": "許可されていない ISP",
"serviceBlocked": "ブロック済み",
"serviceUnsupportedRegion": "対象外の地域",
"serviceOriginalsOnly": "オリジナル作品のみ",
"serviceComingSoon": "近日提供予定",
"servicePending": "未検査",
"serviceManage": "サービスを管理",
"serviceCheck": "検査",
"serviceCheckAll": "すべて検査",
"serviceCheckedAt": "{time} に検査",
"upload": "アップロード",
"download": "ダウンロード",
"usedTraffic": "使用済みトラフィック",
"totalTraffic": "合計トラフィック",
"expireTime": "有効期限",
"nullProfileDesc": "プロファイルがありません。先にプロファイルを追加してください",
"nullProfileDesc": "プロファイルを追加して始めましょう",
"settings": "設定",
"language": "言語",
"defaultText": "デフォルト",
@@ -36,27 +49,22 @@
"themeDesc": "ダークモードの設定と色の調整",
"override": "上書き",
"allowLan": "LANプロキシ",
"allowLanDesc": "LAN経由でのプロキシ利用を許可します",
"tun": "TUN",
"tunDesc": "管理者モードでのみ有効",
"minimizeOnExit": "終了時に最小化",
"minimizeOnExitDesc": "システム標準の終了動作を変更します",
"autoLaunch": "自動起動",
"autoLaunchDesc": "システム起動時に自動的に起動します",
"silentLaunch": "サイレント起動",
"silentLaunchDesc": "バックグラウンドで起動します",
"silentLaunchDesc": "起動時にウィンドウを表示しません",
"autoRun": "自動実行",
"autoRunDesc": "アプリを開いたときに自動的に実行します",
"logcat": "ログキャプチャ",
"logcatDesc": "無効にするとログの入り口が非表示になります",
"autoCheckUpdate": "更新の自動チェック",
"autoCheckUpdateDesc": "アプリ起動時に更新を自動的にチェックします",
"checkCertificate": "TLS証明書を検証",
"checkCertificateDesc": "信頼できない証明書を拒否します。無効にすると、サブスクリプションやバックアップが中間者攻撃にさらされます",
"accessControl": "アクセス制御",
"accessControlDesc": "プロキシを利用するアプリを設定します",
"application": "アプリケーション",
"applicationDesc": "アプリケーション関連の設定を変更します",
"edit": "編集",
"confirm": "OK",
"update": "更新",
@@ -103,12 +111,14 @@
"show": "表示",
"exit": "終了",
"systemProxy": "システムプロキシ",
"safeMode": "セーフモード",
"safeModeAppTitle": "{appName}(セーフモード)",
"project": "プロジェクト",
"core": "コア",
"tabAnimation": "タブアニメーション",
"desc": "ClashMetaベースのマルチプラットフォーム対応プロキシクライアント。シンプルで使いやすく、オープンソースで広告もありません。",
"startVpn": "VPNを起動しています...",
"stopVpn": "VPNを停止しています...",
"startVpn": "VPNを起動しています…",
"stopVpn": "VPNを停止しています…",
"compatible": "互換モード",
"notSelectedTip": "現在のプロキシグループは選択できません",
"changeProxyFailedTip": "プロキシの切り替えに失敗したため、前回の選択に戻しました",
@@ -137,28 +147,26 @@
"country": "地域",
"search": "検索",
"allowBypass": "アプリによるVPNバイパスを許可",
"allowBypassDesc": "有効にすると、一部のアプリがVPNをバイパスできます",
"externalController": "外部コントローラー",
"externalControllerDesc": "有効にすると、ポート9090でClashコアを制御できます",
"ipv6Desc": "有効にすると、IPv6トラフィックを受信できます",
"app": "アプリ",
"general": "一般",
"systemProxyDesc": "システムプロキシを設定します",
"startupAndBackground": "起動とバックグラウンド",
"requestsAndUpdates": "リクエストと更新",
"logsAndDiagnostics": "ログと診断",
"userAgent": "User-Agent",
"unifiedDelay": "統一遅延",
"unifiedDelayDesc": "ハンドシェイクなどの余分な遅延を除きます",
"tcpConcurrent": "TCP同時接続",
"tcpConcurrentDesc": "有効にすると、TCPの同時接続を許可します",
"geodataLoader": "Geo低メモリモード",
"geodataLoaderDesc": "有効にすると、低メモリのGeoローダーを使用します",
"requests": "リクエスト",
"requestsDesc": "最近のリクエスト記録を表示します",
"recentRequests": "最近のリクエスト",
"findProcessMode": "プロセス検出",
"init": "初期化",
"infiniteTime": "無期限",
"connections": "接続",
"liveConnections": "リアルタイム接続",
"inbound": "インバウンド",
"connectionsDesc": "現在の接続データを表示します",
"intranetIP": "イントラネットIP",
"view": "表示",
"cut": "切り取り",
@@ -182,19 +190,18 @@
"go": "開く",
"externalLink": "外部リンク",
"otherContributors": "その他の貢献者",
"appIconDesign": "アプリアイコンのデザイン",
"autoCloseConnections": "接続を自動的に閉じる",
"autoCloseConnectionsDesc": "ノードの切り替え後、接続を自動的に閉じます",
"onlyStatisticsProxy": "プロキシのみ集計",
"onlyStatisticsProxyDesc": "有効にすると、プロキシのトラフィックのみを集計します",
"onlyStatisticsProxy": "プロキシトラフィックのみ集計",
"showNotificationStopAction": "通知に停止ボタンを表示",
"showNotificationStopActionDesc": "常駐通知に停止ボタンを表示します。これが原因で通知が常に展開される場合はオフにしてください",
"pureBlackMode": "ピュアブラックモード",
"sidebarBlur": "サイドバーのぼかし",
"sidebarBlurDesc": "ウィンドウ背後のデスクトップをぼかしてサイドバーに透過します",
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
"entries": " 件",
"local": "ローカル",
"remote": "リモート",
"remoteBackupDesc": "WebDAVにデータをバックアップします",
"localBackupDesc": "ローカルにデータをバックアップします",
"mode": "モード",
"time": "時刻",
"source": "ソース",
@@ -209,69 +216,119 @@
"profilesSort": "プロファイルの並べ替え",
"start": "開始",
"stop": "停止",
"dnsDesc": "DNS関連の設定を更新します",
"key": "キー",
"value": "値",
"hostsDesc": "Hostsを追加します",
"vpnTip": "変更はVPNの再起動後に有効になります",
"vpnEnableDesc": "VpnServiceでシステムの全トラフィックを自動的にルーティングします",
"options": "オプション",
"loopback": "ループバック解除ツール",
"loopbackDesc": "UWPのループバック解除に使用します",
"enabled": "有効",
"disabled": "無効",
"loopback": "UWP ループバック解除",
"providers": "外部リソース",
"proxyProviders": "プロキシプロバイダー",
"ruleProviders": "ルールプロバイダー",
"appProxyProviders": "アプリのプロキシプロバイダー",
"appRuleProviders": "アプリのルールプロバイダー",
"behavior": "動作",
"format": "形式",
"providerUrlTip": "リモートリソースのみ対応しています",
"subscriptionInfo": "サブスクリプション情報",
"overrideDns": "DNSを上書き",
"overrideDnsDesc": "有効にすると、プロファイル内のDNS設定を上書きします",
"overrideNtp": "NTPを上書き",
"ntpStatusDesc": "システムクロックではなくNTPサーバーから時刻を取得します",
"server": "サーバー",
"ntpInterval": "同期間隔(分)",
"dialerProxy": "ダイヤラープロキシ",
"dialerProxyDesc": "NTPサーバーへの接続に使用するアウトバウンド",
"writeToSystem": "システムに書き込む",
"writeToSystemDesc": "システムクロックも設定します。Androidでは無視されます",
"overrideEntries": "上書き項目",
"addOverrideEntry": "上書き項目を追加",
"discardChanges": "変更を破棄しますか?",
"quickEdit": "クイック編集",
"status": "状態",
"statusDesc": "無効にすると、システムDNSを使用します",
"preferH3Desc": "DoHでHTTP/3を優先します",
"respectRules": "ルールに従う",
"respectRulesDesc": "DNS接続がルールに従います。proxy-server-nameserverの設定が必要です",
"respectRulesDesc": "DNS接続がルールに従います。Proxy Server Nameserverの設定が必要です",
"dnsMode": "DNSモード",
"fakeipRange": "Fake-IP範囲",
"fakeipFilter": "Fake-IPフィルター",
"defaultNameserver": "デフォルトネームサーバー",
"defaultNameserverDesc": "DNSサーバーの名前解決に使用します",
"nameserver": "ネームサーバー",
"nameserverDesc": "ドメインの名前解決に使用します",
"useHosts": "Hostsを使用",
"useSystemHosts": "システムのHostsを使用",
"nameserverPolicy": "ネームサーバーポリシー",
"nameserverPolicyDesc": "ドメインごとのネームサーバーポリシーを指定します",
"proxyNameserver": "プロキシネームサーバー",
"proxyNameserverDesc": "プロキシノードのドメイン解決に使用します",
"fallback": "フォールバック",
"fallbackDesc": "通常は国外のDNSを使用します",
"fallbackFilter": "フォールバックフィルター",
"geoipCode": "GeoIPコード",
"ipcidr": "IP/CIDR",
"domain": "ドメイン",
"listenRoutingMark": "リッスンのルーティングマーク",
"listenRoutingMarkDesc": "Linuxのみ",
"ipv6Timeout": "IPv6タイムアウト(ms)",
"cacheAlgorithm": "キャッシュアルゴリズム",
"cacheMaxSize": "キャッシュサイズ",
"fakeipRange6": "Fake-IP範囲(IPv6)",
"fakeipFilterMode": "Fake-IPフィルターモード",
"fakeipFilterModeDesc": "blacklistは一致を除外、whitelistは一致のみ、ruleはルールで判定",
"fakeipTtl": "Fake-IP TTL",
"reset": "リセット",
"actionView": "表示/非表示",
"actionStart": "開始/停止",
"actionMode": "モード切替",
"actionProxy": "システムプロキシ",
"actionTun": "TUN",
"actionRuleMode": "ルールモード",
"actionGlobalMode": "グローバルモード",
"actionDirectMode": "ダイレクトモード",
"actionDelayTest": "すべての遅延をテスト",
"actionUpdateProfiles": "プロファイルを更新",
"disclaimer": "免責事項",
"disclaimerDesc": "本ソフトウェアは、学習・交流や研究などの非商用目的でのみ使用できます。商用目的での使用は固く禁じられています。いかなる商業行為も本ソフトウェアとは一切関係ありません。",
"disclaimerDesc": "FlClash(以下「本ソフトウェア」)をご利用になる前に、本声明の内容をよくお読みになり、十分にご理解ください。「同意する」をタップすると、以下のすべての条項を読み、理解し、承諾したものとみなされます。同意いただけない場合は「終了」をタップし、本ソフトウェアの使用を中止してください。",
"disclaimerSoftwareTitle": "ソフトウェアの性質",
"disclaimerSoftwareContent": "本ソフトウェアは Clash.Meta(mihomo)コアをベースとしたオープンソースのネットワークプロキシクライアントであり、設定管理、ルールによる振り分け、トラフィック転送などのローカルツール機能のみを提供します。\n\n本ソフトウェア自体は、プロキシサーバー、ノード、サブスクリプション、ネットワーク接続サービスを一切提供しておらず、それらのサービス提供者と提携、代理、保証の関係はありません。",
"disclaimerUsageTitle": "使用の制限",
"disclaimerUsageContent": "本ソフトウェアは学習、交流、技術研究などの非商用目的でのみ使用できます。有償配布、抱き合わせ販売、商用サービスの一部としての利用、本ソフトウェアの名義での事業活動など、あらゆる商用目的での使用を固く禁じます。いかなる商業行為も本ソフトウェアおよび開発者とは一切関係ありません。\n\nお住まいの国または地域の法令に違反する目的での使用を固く禁じます。これには、法に基づくネットワークアクセス制限の回避、違法情報の拡散、サイバー攻撃、他者の権利の侵害などが含まれますが、これらに限りません。",
"disclaimerResponsibilityTitle": "利用者の責任",
"disclaimerResponsibilityContent": "お住まいの国または地域で本ソフトウェアの使用が合法であることはご自身で確認する必要があり、本ソフトウェアを使用したすべての行為とその結果について、ご自身が単独で法的責任を負います。\n\nインポートするサブスクリプション、ノード、設定はご自身の判断で選択したものです。その出所の適法性、内容の安全性、サービスの安定性については、利用者と各提供者の間で解決してください。",
"disclaimerThirdPartyTitle": "サードパーティのコンテンツ",
"disclaimerThirdPartyContent": "サブスクリプションリンク、設定ファイル、ルールセット、スクリプト、外部リソース、外部リンクはすべて第三者が提供するものであり、開発者はその適法性、正確性、安全性、可用性を審査または保証することはできず、また行いません。\n\nサードパーティのコンテンツの使用に起因する情報漏えい、財産上の損失、アカウント停止その他の損失は、利用者と第三者の間で解決するものとし、開発者は一切責任を負いません。",
"disclaimerWarrantyTitle": "無保証",
"disclaimerWarrantyContent": "本ソフトウェアは「現状のまま」かつ「提供可能な範囲で」提供され、商品性、特定目的への適合性、非侵害、継続的な可用性、エラーやセキュリティ上の脆弱性がないことの保証を含め、明示または黙示を問わずいかなる保証も伴いません。\n\n開発者は、本ソフトウェアが利用者の要件を満たすこと、また中断やエラーなく動作することを保証しません。",
"disclaimerLiabilityTitle": "責任の制限",
"disclaimerLiabilityContent": "適用法で認められる最大限の範囲において、開発者およびすべての貢献者は、本ソフトウェアの使用または使用不能に起因する直接的、間接的、偶発的、特別、懲罰的または結果的な損害(データの消失、機器の損傷、ネットワーク障害、業務の中断、逸失利益、およびそれに起因する法的紛争を含むがこれらに限らない)について、その可能性を知らされていた場合であっても、一切責任を負いません。",
"disclaimerPrivacyTitle": "データ収集とプライバシー",
"disclaimerPrivacyContent": "本ソフトウェアは、サブスクリプション URL、ノード情報、設定内容、閲覧したウェブサイト、接続記録、通信内容、ログを収集またはアップロードしません。これらのデータは端末内にのみ保存され、開発者がアクセスすることはできません。\n\n本ソフトウェアが外部ネットワークにアクセスするのは、プロファイル更新時に指定されたサブスクリプション URL へアクセスする場合や、アップデート確認時に GitHub へアクセスする場合など、該当する機能を使用したときのみです。\n\nデスクトップ版(Windows、macOS、Linux)には、統計やクラッシュレポートのサービスは一切組み込まれていません。Android 版には、安定性向上のため以下の 2 つの Google Firebase サービスが組み込まれています。",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(クラッシュ分析)",
"disclaimerCrashlyticsContent": "アプリがクラッシュした際に、クラッシュレポートを自動的に送信します。\n\n収集内容:クラッシュのスタックトレースとエラー情報、発生日時、アプリのバージョンとビルド番号、端末のメーカーとモデル、Android のバージョン、画面の向き、空きメモリと空きストレージ、root 化の有無、およびインストール時に生成され再インストールでリセットされるランダムなインストール ID。\n\n目的:クラッシュの特定と修正のためにのみ使用します。\n\n「ツール > 一般 > クラッシュ分析」からいつでもオフにできます。",
"disclaimerAnalyticsTitle": "Firebase Analytics(利用統計)",
"disclaimerAnalyticsContent": "Firebase により基本的なアプリ利用統計が自動的に収集されます。\n\n収集内容:初回起動、アプリの起動とセッション時間、アプリの更新などの基本イベント、アプリインスタンス ID、端末のモデル、OS のバージョン、システム言語、および IP アドレスから推定される国または地域レベルのおおよその位置情報。\n\n目的:アクティブな端末数、バージョン分布、OS の互換性を把握するためにのみ使用します。開発者はこれらのデータを広告に使用したり、販売したり、サブスクリプションや設定と関連付けたりすることはありません。",
"disclaimerDataProcessingContent": "これらのデータは Google が代わりに処理・保存し、お住まいの国または地域外(米国など)のサーバーに転送される場合があり、Google のプライバシーポリシーおよび Firebase のプライバシーとセキュリティに関する説明に従って取り扱われます。クラッシュレポートは最大 90 日間保存され、統計データは Firebase の既定の保存ポリシーに従って保存されます。",
"disclaimerLicenseTitle": "オープンソースライセンス",
"disclaimerLicenseContent": "本ソフトウェアは GPL-3.0 ライセンスのもとでオープンソースとして公開されています。同ライセンスを遵守する限り、自由に使用、改変、再配布できますが、派生物も GPL-3.0 で公開し、原著作者の著作権表示を保持する必要があります。\n\n本ソフトウェアに含まれるサードパーティのコンポーネント(Clash.Meta コアを含む)は、それぞれのライセンスに従います。改変版や再配布版に起因する問題について、原著作者は責任を負いません。",
"disclaimerChangesTitle": "本声明の変更",
"disclaimerChangesContent": "開発者はバージョンの更新に伴い本声明を変更することがあり、変更内容は新しいバージョンの公開とともに効力を生じます。更新後も本ソフトウェアを引き続き使用した場合、変更後の声明に同意したものとみなされます。",
"disclaimerAcceptTitle": "声明への同意",
"disclaimerAcceptContent": "本ソフトウェアをインストール、複製または使用した時点で、本声明のすべての内容を読み、同意したものとみなされます。いずれかの条項に同意いただけない場合は、直ちに使用を中止し、本ソフトウェアをアンインストールしてください。",
"disclaimerAndroidOnly": "Android のみ",
"disclaimerGooglePrivacy": "Google プライバシーポリシー",
"disclaimerFirebasePrivacy": "Firebase のプライバシーとセキュリティ",
"agree": "同意する",
"hotkeyManagement": "ホットキー管理",
"hotkeyManagementDesc": "キーボードでアプリを操作します",
"pressKeyboard": "キーを押してください",
"inputCorrectHotkey": "正しいホットキーを入力してください",
"hotkeyConflict": "ホットキーが競合しています",
"hotkeyDesc": "グローバルホットキーはウィンドウが非表示でも有効です。アクションをタップしてキーの組み合わせを記録します。",
"hotkeyNotSet": "未設定",
"pressKeyboard": "キーの組み合わせを押してください",
"hotkeyNeedsModifier": "{modifiers} のいずれかを含めてください",
"hotkeyConflictWith": "「{action}」で使用中です。保存するとこちらに移動します。",
"hotkeyUnavailable": "登録できませんでした。他のアプリが使用している可能性があります",
"remove": "削除",
"noHotKey": "ホットキーはまだありません",
"noNetwork": "ネットワークがありません",
"ipv6InboundDesc": "IPv6インバウンドを許可します",
"exportLogs": "ログをエクスポート",
"exportSuccess": "エクスポートが完了しました",
"iconStyle": "アイコンスタイル",
"onlyIcon": "アイコンのみ",
"iconStyleFilled": "背景あり",
"iconStylePlain": "背景なし",
"iconStyleHidden": "非表示",
"filter": "フィルター",
"hideTimeoutProxies": "タイムアウトしたノードを隠す",
"hideTimeoutProxiesDesc": "前回の遅延テストがタイムアウトしたノードを表示しない",
"stackMode": "スタックモード",
"network": "ネットワーク",
"networkDesc": "ネットワーク関連の設定を変更します",
"bypassDomain": "除外ドメイン",
"bypassDomainDesc": "システムプロキシが有効な場合のみ適用されます",
"resetTip": "リセットしてもよろしいですか?",
@@ -284,7 +341,6 @@
"routeModeBypassPrivate": "プライベートアドレスをバイパス",
"routeModeConfig": "設定を使用",
"routeAddress": "ルートアドレス",
"routeAddressDesc": "リッスンするルートアドレスを設定します",
"interfaceNameMode": "アウトバウンドインターフェース",
"interfaceNameModeClear": "クリア",
"interfaceNameModeFollow": "設定に従う",
@@ -293,6 +349,20 @@
"interfaceNameDesc": "アウトバウンド接続に使用するネットワークインターフェース名",
"copyEnvVar": "環境変数をコピー",
"memoryInfo": "メモリ情報",
"total": "合計",
"releaseMemory": "メモリを解放",
"memoryCoreHeapInuse": "使用中のヒープ",
"memoryCoreHeapIdle": "未使用のヒープ",
"memoryCoreStack": "ゴルーチンスタック",
"memoryCoreRuntime": "ランタイムのオーバーヘッド",
"memoryCoreNotRunning": "コアは実行されていません",
"memoryAppResident": "常駐メモリ",
"memoryAppShared": "アプリと共有",
"memoryEstimateDesc": "プロセスの常駐メモリからの推定値で、システムの表示とは異なる場合があります。",
"memoryEstimateSharedDesc": "コアはアプリと同じプロセスで動作します。コア分はランタイム統計から推定し、残りはアプリと共有メモリとして計上します。",
"memoryReleased": "メモリを解放しました",
"memoryReleasedSize": "{size} を解放しました",
"releaseMemoryFailed": "メモリの解放に失敗しました",
"cancel": "キャンセル",
"fileIsUpdate": "ファイルが変更されています。変更を保存しますか?",
"profileHasUpdate": "プロファイルが変更されています。自動更新を無効にしますか?",
@@ -305,27 +375,27 @@
"listen": "リッスン",
"undo": "元に戻す",
"redo": "やり直す",
"lineWrap": "折り返し",
"none": "なし",
"basicConfig": "基本設定",
"basicConfigDesc": "基本設定をグローバルに変更します",
"advancedConfig": "詳細設定",
"advancedConfigDesc": "多彩な設定項目を提供します",
"advancedConfigDesc": "ネットワーク、DNS、追加ルール、スクリプト",
"selectedCountTitle": "{count} 件選択中",
"addRule": "ルールを追加",
"ruleName": "ルール名",
"content": "内容",
"subRule": "サブルール",
"ruleTarget": "ルールターゲット",
"matchTargetTitle": "マッチ先",
"matchTarget": "MATCH-TARGET",
"followProfile": "プロファイルに従う",
"matchTargetDesc": "MATCH-TARGET を対象にしたルールの行き先。既定ではこのプロファイル末尾の MATCH ルールのターゲットを使います。",
"selectMatchTarget": "MATCH-TARGET を選択",
"sourceIp": "送信元IP",
"noResolve": "IPを解決しない",
"saveChanges": "変更を保存しますか?",
"findProcessModeDesc": "有効にすると、パフォーマンスが多少低下します",
"tabAnimationDesc": "モバイル表示でのみ有効です",
"navigationBarStyle": "ボトムバー",
"floating": "フローティング",
"docked": "固定",
"slide": "スライド",
"fade": "フェード",
"pureBlack": "ピュアブラック",
"colorSchemes": "カラースキーム",
"palette": "パレット",
"tonalSpotScheme": "トーナルスポット",
@@ -348,6 +418,8 @@
"launchInterruptedTip": "前回、アプリは起動中に予期せず終了しました。今回の自動セットアップはスキップしました。手動で起動して再試行できます。",
"clearData": "データを消去",
"textScale": "テキストの拡大縮小",
"textScalePreview": "アプリ内の文字はこの大きさで表示されます",
"followSystem": "システムに従う",
"internet": "インターネット",
"systemApp": "システムアプリ",
"noNetworkApp": "ネットワーク不使用アプリ",
@@ -361,7 +433,13 @@
"interval": "間隔",
"existsTip": "{label}はすでに存在します",
"maxLengthTip": "{label}は最大{max}文字です",
"multipleValuesTip": "複数の値はカンマで区切ってください",
"singleAdd": "個別追加",
"batchAdd": "一括追加",
"batchListInputTip": "1行に1項目、またはカンマ区切りで入力してください",
"batchMapInputTip": "1行に1件、キーと値はスペースで区切ってください",
"batchPreviewTip": "{count}件を追加、{skipped}件は既存のためスキップ",
"lineIssueTip": "{line}行目:{message}",
"singleValueTip": "{label}は1項目のみ指定できます",
"deleteTip": "この{label}を削除してもよろしいですか?",
"deleteMultipTip": "選択した{label}を削除してもよろしいですか?",
"nullTip": "{label}はまだありません",
@@ -378,6 +456,7 @@
"import": "インポート",
"importFile": "ファイルからインポート",
"importUrl": "URLからインポート",
"startFromScratch": "最初から作成",
"autoSetSystemDns": "システムDNSを自動設定",
"details": "{label}の詳細",
"creationTime": "作成日時",
@@ -396,7 +475,7 @@
"request": "リクエスト",
"connected": "接続済み",
"disconnected": "切断済み",
"connecting": "接続中...",
"connecting": "接続中…",
"restartCoreTip": "コアを再起動してもよろしいですか?",
"forceRestartCoreTip": "コアを強制再起動してもよろしいですか?",
"dnsHijacking": "DNSハイジャック",
@@ -406,20 +485,25 @@
"crashlytics": "クラッシュ分析",
"crashlyticsTip": "有効にすると、アプリのクラッシュ時に機密情報を含まないクラッシュログを自動的にアップロードします",
"appendSystemDns": "システムDNSを追加",
"appendSystemDnsTip": "設定にシステムDNSを強制的に追加します",
"editRule": "ルールを編集",
"overrideMode": "上書きモード",
"standardModeDesc": "標準モード:基本設定を上書きし、シンプルなルール追加機能を提供します",
"scriptModeDesc": "スクリプトモード:外部の拡張スクリプトを使用し、ワンクリックで設定を上書きします",
"addedRules": "追加ルール",
"controlGlobalAddedRules": "グローバル追加ルールを管理",
"quickAdd": "クイック追加",
"rulePresetBlockQuic": "QUIC をブロック",
"rulePresetBlockStun": "STUN をブロック",
"rulePresetBlockDot": "DNS over TLS をブロック",
"rulePresetSystemServicesDirect": "Apple と Microsoft に直接接続",
"rulePresetBittorrentDirect": "BitTorrent を直接接続",
"rulePresetLanDirect": "LAN 直接接続",
"overrideScript": "上書きスクリプト",
"goToConfigureScript": "スクリプト設定へ移動",
"editGlobalRules": "グローバルルールを編集",
"externalFetch": "外部取得",
"confirmForceCrashCore": "コアを強制クラッシュさせてもよろしいですか?",
"confirmClearAllData": "すべてのデータを消去してもよろしいですか?",
"loading": "読み込み中...",
"loading": "読み込み中…",
"yearsAgo": "{count} 年前",
"monthsAgo": "{count} か月前",
"daysAgo": "{count} 日前",
@@ -435,24 +519,31 @@
"speedStatistics": "速度統計",
"resetPageChangesTip": "このページには変更があります。リセットしてもよろしいですか?",
"overwriteTypeCustom": "カスタム",
"overwriteTypeCustomDesc": "カスタムモード:プロキシグループとルールを完全にカスタマイズできます",
"overwriteTypeCustomDesc": "カスタムモード:プロキシ、プロキシグループ、ルールを完全にカスタマイズできます",
"unknownNetworkError": "不明なネットワークエラー",
"restoreException": "復元エラー",
"networkException": "ネットワークエラーです。接続を確認してから再試行してください",
"backupFromNewerVersion": "このバックアップは新しいバージョンのアプリで作成されています。アプリを更新してから復元してください",
"networkTimeoutError": "リクエストがタイムアウトしました。ネットワークまたはプロキシを確認してから再試行してください",
"networkHostLookupError": "サーバーのアドレスを解決できませんでした。URL が正しいこと、DNS が使えることを確認してください",
"networkConnectionError": "サーバーに接続できませんでした。ネットワーク接続またはプロキシ設定を確認してください",
"networkTlsError": "安全な接続に失敗しました。サーバー証明書が無効か、接続が傍受されている可能性があります",
"networkCancelledError": "リクエストはキャンセルされました",
"networkAccessDeniedError": "サーバーがアクセスを拒否しました(HTTP {code})。リンクの期限切れか、認証情報が誤っている可能性があります",
"networkNotFoundError": "このアドレスには何も見つかりませんでした(HTTP {code})。URL が正しいか確認してください",
"networkRateLimitedError": "リクエストが多すぎます(HTTP 429)。しばらく待ってから再試行してください",
"networkServerError": "サーバーで問題が発生しました(HTTP {code})。しばらくしてから再試行してください",
"networkBadResponseError": "サーバーがリクエストを拒否しました(HTTP {code})",
"networkRequestFailed": "ネットワークリクエストに失敗しました:{detail}",
"invalidBackupFile": "無効なバックアップファイル",
"pruneCache": "キャッシュを整理",
"backupAndRestore": "バックアップと復元",
"backupAndRestoreDesc": "WebDAVまたはファイルでデータを同期します",
"restore": "復元",
"restoreSuccess": "復元が完了しました",
"restoreFromWebDAVDesc": "WebDAVからデータを復元します",
"restoreFromFileDesc": "ファイルからデータを復元します",
"restoreOnlyConfig": "プロファイルのみ復元",
"restoreAllData": "すべてのデータを復元",
"addProfile": "プロファイルを追加",
"delayTest": "遅延テスト",
"proxyGroupEmpty": "プロキシグループが空です",
"proxyGroupNameEmpty": "プロキシグループ名は空にできません",
"proxyGroupNameDuplicate": "プロキシグループ名が重複しています",
"confirmExitWindow": "現在のウィンドウを閉じてもよろしいですか?",
"dataChangedSave": "データの変更を検出しました。保存しますか?",
@@ -464,6 +555,8 @@
"excludeProxyFilter": "除外ノードフィルター",
"excludeType": "除外タイプ",
"expectedStatus": "期待するステータス",
"strategy": "戦略",
"tolerance": "許容値",
"selectProxies": "プロキシを選択",
"inputProxyGroupName": "プロキシグループ名を入力してください",
"helperCorruptTip": "Helper サービスが利用できないため、TUN モードを有効にできません。FlClash を再インストールしてください。",
@@ -488,7 +581,7 @@
"basicStrategy": "基本ポリシー",
"editProxy": "プロキシを編集",
"includeAllProxyProviders": "すべてのプロキシプロバイダーを含める",
"includeAllProxyProvidersTip": "有効にすると、取り込んだプロキシプロバイダーを上書きします",
"includeAllProxyProvidersTip": "有効にすると、このプロファイルのすべてのプロキシプロバイダーを含めます。サブスクリプション自身のものに加え、いずれかのプロキシグループが使うプロファイルとアプリのプロキシプロバイダーも含みます",
"addProxyProviders": "プロキシプロバイダーを追加",
"includeAllProxies": "すべてのプロキシを含める",
"includeAllProxiesTip": "プロキシグループに属さないすべてのプロキシを取り込みます。下でプロキシグループを追加できます",
@@ -550,17 +643,16 @@
"invalidSubRule": "{subRule} は無効な SUB_RULE です",
"invalidPolicy": "{target} は無効なポリシーです",
"invalidProxyProvider": "{providerName} は無効なプロキシプロバイダーです",
"invalidRuleSet": "{ruleSet} は無効なルールセットです",
"invalidNetworkContent": "tcp または udp のみ対応しています",
"invalidRangeContent": "80 や 8000-9000 のような数値または範囲を / 区切りで入力してください",
"invalidDscpContent": "DSCP マークは 63 を超えられません",
"invalidProxy": "{proxyName} は無効なプロキシです",
"proxyGroupDetectedAbnormal": "現在のプロキシグループに異常が見つかりました",
"proxyProviderDetectedAbnormal": "選択したプロキシプロバイダーに異常が見つかりました",
"proxyDetectedAbnormal": "選択したプロキシに異常が見つかりました",
"createProfile": "プロファイルを作成",
"locationPermissionRequired": "位置情報の権限が必要です",
"locationPermissionGuide": "1. システム設定 > プライバシーとセキュリティ を開く\n2. 位置情報サービス を選択\n3. リストで {appName} を見つけてチェックを入れる\n\n設定が完了したらアプリに戻ると、通常どおり使用できます。ご協力ありがとうございます。",
"prerequisites": "前提条件",
"ignoreBatteryOptimization": "電池の最適化を無視",
"batteryOptimizationDesc": "バックグラウンドでの動作を維持するため、このアプリの電池の最適化を無効にしてください。タップすると設定を開きます。",
"batteryOptimizationStatusTip": "システムの制限により、実行中は電池の最適化の状態を正しく取得できません",
"locationPermission": "位置情報の権限",
"locationPermissionDesc": "システムの要件により、Wi-Fi 名の取得には位置情報の権限が必要です。Android では「常に許可」を選択してください。そうしないと、アプリがバックグラウンドにあるときに Wi-Fi 名を取得できません。",
"excludeSsids": "除外SSID",
@@ -573,7 +665,7 @@
"editSsid": "SSIDを編集",
"authorized": "許可済み",
"tapToAuthorize": "タップして許可",
"suspended": "一時停止中...",
"suspended": "一時停止中…",
"geoOptions": "Geoオプション",
"geoAutoUpdate": "自動更新",
"geoAutoUpdateInterval": "自動更新間隔",
@@ -584,7 +676,6 @@
"geoSkipped": "{name} はすでに最新です",
"geoUpdated": "{name} を更新しました",
"secondsCount": "{count} 秒",
"entriesCount": "{count} 件",
"proxiesCount": "プロキシ {count} 件",
"rulesCount": "ルール {count} 件",
"changelogBreaking": "破壊的変更",
@@ -600,7 +691,6 @@
"pinWindow": "最前面に固定",
"unpinWindow": "固定を解除",
"exitFullScreen": "全画面表示を終了",
"toggleLabel": "ラベルを切り替え",
"torch": "ライト",
"pickFromAlbum": "アルバムから選択",
"blockConnection": "接続をブロック",
@@ -611,11 +701,76 @@
"previousMatch": "前の一致",
"nextMatch": "次の一致",
"clearSearch": "検索をクリア",
"noSearchResults": "一致する結果はありません",
"addWidget": "ウィジェットを追加",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"authorize": "許可",
"installedAppsPermissionRequired": "アプリ一覧の権限が必要です",
"installedAppsPermissionDesc": "このシステムでは、許可するまでインストール済みアプリの一覧が提供されません。許可すると、アプリごとのプロキシを設定できます。",
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。"
"installedAppsPermissionDeniedMessage": "アプリ一覧の権限が拒否されたため、インストール済みアプリを取得できません。システム設定から手動で許可してください。",
"outboundIp": "アウトバウンド IP",
"ipAddress": "IP アドレス",
"ipQualityLevel": "レベル",
"ipQualityGood": "良好",
"ipQualityNormal": "普通",
"ipQualityRisky": "リスクあり",
"ipType": "タイプ",
"ipTypeResidential": "住宅",
"ipTypeMobile": "モバイル回線",
"ipTypeBusiness": "ビジネス",
"ipTypeHosting": "データセンター",
"ipFlags": "検出",
"ipFlagProxy": "プロキシ",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "不正利用の履歴",
"ipOrganization": "組織",
"ipAsn": "ASN",
"ipQualitySource": "採用したソース",
"ipQualitySources": "各ソース",
"ipQualityFailed": "IP タイプを判定できませんでした",
"ipQualityRetry": "再確認",
"ipSourceNoType": "判定不可",
"ipSourceRateLimited": "レート制限",
"ipSourceIpMismatch": "アウトバウンド IP が不一致",
"hideIp": "IP を隠す",
"editorUnavailable": "エディターを利用できません",
"replace": "置換",
"replaceAll": "すべて置換",
"cameraPermissionRequired": "カメラの権限が必要です",
"cameraPermissionDesc": "QRコードをスキャンするには、システム設定でカメラへのアクセスを許可するか、アルバムからQRコード画像を選択してください。",
"cameraUnavailable": "カメラを使用できません",
"retry": "再試行",
"invalidProfileQrcode": "このQRコードにはプロファイルのリンクが含まれていません",
"dnsQueries": "DNSクエリ",
"recordType": "レコードタイプ",
"answers": "応答",
"responseCode": "応答コード",
"error": "エラー",
"initiator": "発信元",
"cache": "キャッシュ",
"proxyNode": "プロキシノード",
"yes": "はい",
"no": "いいえ",
"nonTextProviderFile": "この外部リソースはテキストファイルではありません",
"addCustomProxy": "プロキシを追加",
"proxyDefinition": "完全な設定",
"proxyDefinitionNotMap": "設定は name と type を含む YAML マッピングである必要があります",
"customProxiesEmpty": "カスタムプロキシがないため、プロファイル自身のプロキシを使用します",
"overwriteIssueEmptyName": "名前が空です",
"overwriteIssueReservedName": "{name} は組み込みポリシー名のため使用できません",
"overwriteIssueDuplicateName": "名前 {name} は他のプロキシまたはプロキシグループで使用されています",
"overwriteIssueCoreRejected": "コアがこのプロキシを解析できません:{message}",
"overwriteIssueMissingProxies": "次のプロキシまたはポリシーは存在しません:{names}",
"overwriteIssueMissingProviders": "次のプロキシプロバイダーは存在しません:{names}",
"overwriteIssueNoProxySource": "プロキシもプロキシプロバイダーも選択されていないため、コアはこのグループを拒否します",
"overwriteIssueGroupLoop": "プロキシグループが循環参照しています:{path}",
"overwriteIssuesSummary": "{count} 件に問題があり、上書きの適用に失敗する可能性があります",
"overwriteIssueSubscriptionGroupMissingProxies": "プロファイル自身のプロキシグループが、カスタムプロキシに含まれないプロキシを参照しています:{names}",
"overwriteIssueProviderShadowed": "プロファイルに {name} という名前のプロバイダーが既にあるため、同名のアプリレベルのプロバイダーは使用されません。名前を変更してください",
"providerInUse": "{label} は {profiles} のカスタムプロキシグループまたはルールでまだ使用されています。先にそこから外してください",
"providerSourceSubscription": "サブスクリプション",
"providerRenameShadowed": "{profiles} のサブスクリプションには既に {label} があるため、名前を変えるとそちらが使われます。別の名前にしてください",
"appProviderShadowed": "同名のアプリ側の項目はここでは使われません"
}
+235 -80
View File
@@ -9,19 +9,32 @@
"profiles": "Профили",
"tools": "Инструменты",
"logs": "Логи",
"logsDesc": "Записи захваченных логов",
"resources": "Ресурсы",
"resourcesDesc": "Сведения о внешних ресурсах",
"trafficUsage": "Статистика трафика",
"networkSpeed": "Скорость сети",
"outboundMode": "Режим исходящего трафика",
"networkDetection": "Проверка сети",
"serviceStatus": "Состояние сервисов",
"serviceRestricted": "Доступ ограничен",
"serviceAvailable": "Доступен",
"serviceUnavailable": "Недоступен",
"serviceFailed": "Ошибка проверки",
"serviceDisallowedIsp": "Недопустимый провайдер",
"serviceBlocked": "Заблокировано",
"serviceUnsupportedRegion": "Регион не поддерживается",
"serviceOriginalsOnly": "Только оригиналы",
"serviceComingSoon": "Скоро появится",
"servicePending": "Не проверено",
"serviceManage": "Управление сервисами",
"serviceCheck": "Проверить",
"serviceCheckAll": "Проверить все",
"serviceCheckedAt": "Проверено в {time}",
"upload": "Отдача",
"download": "Загрузка",
"usedTraffic": "Использованный трафик",
"totalTraffic": "Общий трафик",
"expireTime": "Срок действия",
"nullProfileDesc": "Профилей пока нет. Сначала добавьте профиль",
"nullProfileDesc": "Добавьте профиль, чтобы начать",
"settings": "Настройки",
"language": "Язык",
"defaultText": "По умолчанию",
@@ -36,27 +49,22 @@
"themeDesc": "Тёмный режим и настройка цветов",
"override": "Переопределение",
"allowLan": "Разрешить LAN",
"allowLanDesc": "Разрешить доступ к прокси из локальной сети",
"tun": "TUN",
"tunDesc": "Работает только в режиме администратора",
"minimizeOnExit": "Сворачивать при выходе",
"minimizeOnExitDesc": "Изменяет стандартное поведение при выходе",
"autoLaunch": "Автозапуск",
"autoLaunchDesc": "Запускаться автоматически при старте системы",
"silentLaunch": "Тихий запуск",
"silentLaunchDesc": "Запускаться в фоновом режиме",
"silentLaunchDesc": "Запускаться без показа окна",
"autoRun": "Автовключение",
"autoRunDesc": "Включаться автоматически при открытии приложения",
"logcat": "Захват логов",
"logcatDesc": "При отключении раздел логов будет скрыт",
"autoCheckUpdate": "Автопроверка обновлений",
"autoCheckUpdateDesc": "Автоматически проверять обновления при запуске приложения",
"checkCertificate": "Проверять TLS-сертификаты",
"checkCertificateDesc": "Отклонять недоверенные сертификаты. Отключение подвергает подписки и резервные копии атаке «человек посередине»",
"accessControl": "Контроль доступа",
"accessControlDesc": "Выбор приложений, использующих прокси",
"application": "Приложение",
"applicationDesc": "Настройки, связанные с приложением",
"edit": "Редактировать",
"confirm": "Подтвердить",
"update": "Обновить",
@@ -103,12 +111,14 @@
"show": "Показать",
"exit": "Выход",
"systemProxy": "Системный прокси",
"safeMode": "Безопасный режим",
"safeModeAppTitle": "{appName} (Безопасный режим)",
"project": "Проект",
"core": "Ядро",
"tabAnimation": "Анимация вкладок",
"desc": "Многоплатформенный прокси-клиент на основе ClashMeta: простой и удобный, с открытым исходным кодом и без рекламы.",
"startVpn": "Запуск VPN...",
"stopVpn": "Остановка VPN...",
"startVpn": "Запуск VPN…",
"stopVpn": "Остановка VPN…",
"compatible": "Режим совместимости",
"notSelectedTip": "Текущую группу прокси нельзя выбрать",
"changeProxyFailedTip": "Не удалось переключить прокси; восстановлен предыдущий выбор",
@@ -137,28 +147,26 @@
"country": "Регион",
"search": "Поиск",
"allowBypass": "Разрешить приложениям обходить VPN",
"allowBypassDesc": "При включении некоторые приложения смогут обходить VPN",
"externalController": "Внешний контроллер",
"externalControllerDesc": "При включении ядром Clash можно управлять через порт 9090",
"ipv6Desc": "При включении можно принимать трафик IPv6",
"app": "Приложение",
"general": "Общие",
"systemProxyDesc": "Настроить системный прокси",
"startupAndBackground": "Запуск и фоновая работа",
"requestsAndUpdates": "Запросы и обновления",
"logsAndDiagnostics": "Логи и диагностика",
"userAgent": "User-Agent",
"unifiedDelay": "Единая задержка",
"unifiedDelayDesc": "Убирает лишние задержки, например рукопожатие",
"tcpConcurrent": "Параллельный TCP",
"tcpConcurrentDesc": "При включении разрешает параллельные TCP-подключения",
"geodataLoader": "Geo: экономия памяти",
"geodataLoaderDesc": "При включении используется Geo-загрузчик с низким потреблением памяти",
"requests": "Запросы",
"requestsDesc": "Просмотр последних запросов",
"recentRequests": "Последние запросы",
"findProcessMode": "Поиск процесса",
"init": "Инициализация",
"infiniteTime": "Бессрочно",
"connections": "Соединения",
"liveConnections": "Активные соединения",
"inbound": "Входящие",
"connectionsDesc": "Просмотр данных о текущих соединениях",
"intranetIP": "Внутренний IP",
"view": "Просмотр",
"cut": "Вырезать",
@@ -182,19 +190,18 @@
"go": "Перейти",
"externalLink": "Внешняя ссылка",
"otherContributors": "Другие участники",
"appIconDesign": "Дизайн значка приложения",
"autoCloseConnections": "Автозакрытие соединений",
"autoCloseConnectionsDesc": "Автоматически закрывать соединения после смены узла",
"onlyStatisticsProxy": "Учитывать только прокси",
"onlyStatisticsProxyDesc": "При включении учитывается только трафик через прокси",
"onlyStatisticsProxy": "Учитывать только трафик прокси",
"showNotificationStopAction": "Кнопка остановки в уведомлении",
"showNotificationStopActionDesc": "Показывать кнопку остановки в постоянном уведомлении. Отключите, если из-за неё система всегда разворачивает уведомление",
"pureBlackMode": "Чисто чёрный режим",
"sidebarBlur": "Размытие боковой панели",
"sidebarBlurDesc": "Показывать сквозь боковую панель размытый рабочий стол за окном",
"keepAliveIntervalDesc": "Интервал TCP keep-alive",
"entries": " записей",
"local": "Локально",
"remote": "Удалённо",
"remoteBackupDesc": "Резервное копирование данных в WebDAV",
"localBackupDesc": "Резервное копирование данных локально",
"mode": "Режим",
"time": "Время",
"source": "Источник",
@@ -209,69 +216,119 @@
"profilesSort": "Сортировка профилей",
"start": "Старт",
"stop": "Стоп",
"dnsDesc": "Настройки, связанные с DNS",
"key": "Ключ",
"value": "Значение",
"hostsDesc": "Добавить записи hosts",
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
"vpnEnableDesc": "Автоматически направляет весь системный трафик через VpnService",
"options": "Опции",
"loopback": "Инструмент разблокировки loopback",
"loopbackDesc": "Для снятия ограничения loopback у UWP-приложений",
"enabled": "Включено",
"disabled": "Выключено",
"loopback": "Снятие ограничения loopback для UWP",
"providers": "Внешние ресурсы",
"proxyProviders": "Провайдеры прокси",
"ruleProviders": "Провайдеры правил",
"appProxyProviders": "Прокси-провайдеры приложения",
"appRuleProviders": "Провайдеры правил приложения",
"behavior": "Поведение",
"format": "Формат",
"providerUrlTip": "Поддерживаются только удалённые ресурсы",
"subscriptionInfo": "Информация о подписке",
"overrideDns": "Переопределить DNS",
"overrideDnsDesc": "При включении настройки DNS профиля переопределяются",
"overrideNtp": "Переопределить NTP",
"ntpStatusDesc": "Брать время с NTP-сервера, а не из системных часов",
"server": "Сервер",
"ntpInterval": "Интервал синхронизации (минуты)",
"dialerProxy": "Прокси для подключения",
"dialerProxyDesc": "Исход, через который идёт обращение к NTP-серверу",
"writeToSystem": "Записывать в систему",
"writeToSystemDesc": "Также устанавливать системные часы; Android это игнорирует",
"overrideEntries": "Переопределяемые параметры",
"addOverrideEntry": "Добавить параметр",
"discardChanges": "Отменить изменения?",
"quickEdit": "Быстрое редактирование",
"status": "Статус",
"statusDesc": "При отключении используется системный DNS",
"preferH3Desc": "Предпочитать HTTP/3 для DoH",
"respectRules": "Соблюдать правила",
"respectRulesDesc": "DNS-соединения следуют правилам; требуется настроить proxy-server-nameserver",
"respectRulesDesc": "DNS-соединения следуют правилам; требуется Proxy Server Nameserver",
"dnsMode": "Режим DNS",
"fakeipRange": "Диапазон Fake-IP",
"fakeipFilter": "Фильтр Fake-IP",
"defaultNameserver": "DNS-сервер по умолчанию",
"defaultNameserverDesc": "Используется для разрешения адресов DNS-серверов",
"nameserver": "DNS-сервер",
"nameserverDesc": "Используется для разрешения доменов",
"useHosts": "Использовать hosts",
"useSystemHosts": "Использовать системный hosts",
"nameserverPolicy": "Политика DNS-серверов",
"nameserverPolicyDesc": "Задать политику DNS-серверов для доменов",
"proxyNameserver": "DNS-сервер для прокси",
"proxyNameserverDesc": "Используется для разрешения доменов прокси-узлов",
"fallback": "Fallback",
"fallbackDesc": "Обычно зарубежный DNS",
"fallbackFilter": "Фильтр fallback",
"geoipCode": "Код GeoIP",
"ipcidr": "IP/CIDR",
"domain": "Домен",
"listenRoutingMark": "Метка маршрутизации",
"listenRoutingMarkDesc": "Только Linux",
"ipv6Timeout": "Тайм-аут IPv6 (мс)",
"cacheAlgorithm": "Алгоритм кэша",
"cacheMaxSize": "Размер кэша",
"fakeipRange6": "Диапазон Fake-IP (IPv6)",
"fakeipFilterMode": "Режим фильтра Fake-IP",
"fakeipFilterModeDesc": "blacklist исключает совпадения, whitelist — только их, rule — по правилам",
"fakeipTtl": "TTL Fake-IP",
"reset": "Сброс",
"actionView": "Показать/Скрыть",
"actionStart": "Старт/Стоп",
"actionMode": "Переключить режим",
"actionProxy": "Системный прокси",
"actionTun": "TUN",
"actionRuleMode": "Режим правил",
"actionGlobalMode": "Глобальный режим",
"actionDirectMode": "Прямой режим",
"actionDelayTest": "Проверить все задержки",
"actionUpdateProfiles": "Обновить профили",
"disclaimer": "Отказ от ответственности",
"disclaimerDesc": "Это программное обеспечение предназначено только для некоммерческого использования: обучения, обмена опытом и научных исследований. Коммерческое использование строго запрещено; любая коммерческая деятельность не имеет отношения к этому программному обеспечению.",
"disclaimerDesc": "Перед использованием FlClash (далее — «Программа») внимательно прочитайте это заявление и убедитесь, что понимаете его полностью. Нажимая «Согласен», вы подтверждаете, что прочитали, поняли и принимаете все приведённые ниже условия. Если вы не согласны, нажмите «Выход» и прекратите использование Программы.",
"disclaimerSoftwareTitle": "Характер программы",
"disclaimerSoftwareContent": "Программа — это клиент сетевого прокси с открытым исходным кодом на основе ядра Clash.Meta (mihomo). Она предоставляет только локальные инструменты: управление конфигурациями, маршрутизацию по правилам и пересылку трафика.\n\nСама Программа не предоставляет прокси-серверы, узлы, подписки или услуги доступа к сети и не состоит в партнёрских, агентских или гарантийных отношениях с поставщиками таких услуг.",
"disclaimerUsageTitle": "Ограничения использования",
"disclaimerUsageContent": "Программа предназначена только для некоммерческого использования: обучения, обмена опытом и технических исследований. Любое коммерческое использование строго запрещено, включая, помимо прочего, платное распространение, продажу в комплекте, использование в составе коммерческого сервиса или ведение деятельности от имени Программы. Любая коммерческая деятельность не имеет отношения к Программе и её разработчикам.\n\nСтрого запрещено использовать Программу для действий, нарушающих законы вашей страны или региона, включая, помимо прочего, обход законно установленных ограничений доступа к сети, распространение незаконной информации, сетевые атаки и нарушение законных прав других лиц.",
"disclaimerResponsibilityTitle": "Ваша ответственность",
"disclaimerResponsibilityContent": "Вы самостоятельно убеждаетесь, что использование Программы законно в вашей стране или регионе, и единолично несёте юридическую ответственность за все действия с ней и их последствия.\n\nПодписки, узлы и конфигурации, которые вы импортируете, выбираете вы сами. Законность их источника, безопасность содержимого и надёжность сервиса — вопрос между вами и их поставщиками.",
"disclaimerThirdPartyTitle": "Сторонний контент",
"disclaimerThirdPartyContent": "Ссылки на подписки, файлы конфигурации, наборы правил, скрипты, внешние ресурсы и внешние ссылки предоставляются третьими лицами. Разработчики не могут проверять и не проверяют их законность, точность, безопасность и доступность и не дают на них никаких гарантий.\n\nУтечка данных, финансовые потери, блокировка аккаунта или иной ущерб, вызванные сторонним контентом, урегулируются между вами и третьим лицом; разработчики не несут за это никакой ответственности.",
"disclaimerWarrantyTitle": "Отсутствие гарантий",
"disclaimerWarrantyContent": "Программа предоставляется «как есть» и «по мере доступности», без каких-либо явных или подразумеваемых гарантий, включая, помимо прочего, гарантии товарной пригодности, пригодности для определённой цели, ненарушения прав, бесперебойной работы, отсутствия ошибок и уязвимостей.\n\nРазработчики не гарантируют, что Программа будет соответствовать вашим потребностям или работать без сбоев и ошибок.",
"disclaimerLiabilityTitle": "Ограничение ответственности",
"disclaimerLiabilityContent": "В максимальной степени, допустимой применимым законодательством, ни разработчики, ни участники проекта не несут ответственности за любой прямой, косвенный, случайный, особый, штрафной или последующий ущерб, возникший в результате использования или невозможности использования Программы, включая, помимо прочего, потерю данных, повреждение устройства, сбои сети, прерывание деятельности, упущенную выгоду или связанные с этим правовые споры, даже если они были предупреждены о возможности такого ущерба.",
"disclaimerPrivacyTitle": "Сбор данных и конфиденциальность",
"disclaimerPrivacyContent": "Программа не собирает и не отправляет адреса ваших подписок, сведения об узлах, содержимое конфигураций, посещённые сайты, записи о подключениях, содержимое трафика и журналы. Эти данные хранятся только на вашем устройстве, и у разработчиков нет к ним доступа.\n\nПрограмма обращается к сети только при использовании соответствующих функций, например загружает указанный вами адрес подписки при обновлении профиля или обращается к GitHub при проверке обновлений.\n\nНастольные версии (Windows, macOS, Linux) не содержат никаких сервисов статистики или отчётов о сбоях. Версия для Android использует два сервиса Google Firebase для повышения стабильности:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics (аналитика сбоев)",
"disclaimerCrashlyticsContent": "При сбое приложения отчёт о сбое отправляется автоматически.\n\nЧто собирается: трассировка стека и сообщение об ошибке, время сбоя, версия и номер сборки приложения, производитель и модель устройства, версия Android, ориентация экрана, свободная память и место в хранилище, наличие root-доступа, а также случайный идентификатор установки, который создаётся при установке и сбрасывается при переустановке.\n\nЦель: только поиск и исправление сбоев.\n\nВы можете отключить это в любой момент: «Инструменты > Общие > Аналитика сбоев».",
"disclaimerAnalyticsTitle": "Firebase Analytics (статистика использования)",
"disclaimerAnalyticsContent": "Вместе с Firebase автоматически собирается базовая статистика использования приложения.\n\nЧто собирается: базовые события, такие как первый запуск, открытие приложения и длительность сеанса, обновление приложения; идентификатор экземпляра приложения; модель устройства, версия ОС и язык системы; приблизительное местоположение на уровне страны или региона, определённое по IP-адресу.\n\nЦель: только оценка числа активных устройств, распределения версий и совместимости с ОС. Разработчики не используют эти данные для рекламы, не продают их и не связывают с вашими подписками или конфигурациями.",
"disclaimerDataProcessingContent": "Эти данные обрабатываются и хранятся компанией Google от нашего имени, могут передаваться на серверы за пределами вашей страны или региона (например, в США) и регулируются Политикой конфиденциальности Google и документацией Firebase о конфиденциальности и безопасности. Отчёты о сбоях хранятся до 90 дней; статистика хранится в соответствии с политикой хранения Firebase по умолчанию.",
"disclaimerLicenseTitle": "Лицензия с открытым исходным кодом",
"disclaimerLicenseContent": "Программа распространяется с открытым исходным кодом по лицензии GPL-3.0. Вы можете свободно использовать, изменять и распространять её при соблюдении этой лицензии: производные работы также должны распространяться по GPL-3.0 с сохранением уведомлений об авторских правах.\n\nСторонние компоненты Программы, включая ядро Clash.Meta, распространяются по своим собственным лицензиям. Авторы оригинала не несут ответственности за проблемы, возникшие в изменённых или распространяемых третьими лицами версиях.",
"disclaimerChangesTitle": "Изменения заявления",
"disclaimerChangesContent": "Разработчики могут изменять это заявление в любом выпуске; изменения вступают в силу с момента публикации выпуска. Продолжая пользоваться Программой после обновления, вы принимаете изменённое заявление.",
"disclaimerAcceptTitle": "Принятие условий",
"disclaimerAcceptContent": "Устанавливая, копируя или используя Программу, вы подтверждаете, что прочитали и приняли это заявление полностью. Если вы не согласны с каким-либо его условием, немедленно прекратите использование и удалите Программу.",
"disclaimerAndroidOnly": "Только Android",
"disclaimerGooglePrivacy": "Политика конфиденциальности Google",
"disclaimerFirebasePrivacy": "Конфиденциальность и безопасность Firebase",
"agree": "Согласен",
"hotkeyManagement": "Горячие клавиши",
"hotkeyManagementDesc": "Управление приложением с клавиатуры",
"pressKeyboard": "Нажмите клавишу",
"inputCorrectHotkey": "Введите корректную горячую клавишу",
"hotkeyConflict": "Конфликт горячих клавиш",
"hotkeyDesc": "Глобальные горячие клавиши работают, даже когда окно скрыто. Нажмите на действие, чтобы записать сочетание клавиш.",
"hotkeyNotSet": "Не задано",
"pressKeyboard": "Нажмите сочетание клавиш",
"hotkeyNeedsModifier": "Добавьте хотя бы одну из клавиш: {modifiers}",
"hotkeyConflictWith": "Уже используется для «{action}». При сохранении будет перенесено сюда.",
"hotkeyUnavailable": "Не зарегистрировано: сочетание может быть занято другим приложением",
"remove": "Убрать",
"noHotKey": "Горячих клавиш пока нет",
"noNetwork": "Нет сети",
"ipv6InboundDesc": "Разрешить входящий IPv6",
"exportLogs": "Экспорт логов",
"exportSuccess": "Экспорт выполнен",
"iconStyle": "Стиль значков",
"onlyIcon": "Только значок",
"iconStyleFilled": "С подложкой",
"iconStylePlain": "Без подложки",
"iconStyleHidden": "Скрыто",
"filter": "Фильтр",
"hideTimeoutProxies": "Скрывать узлы с таймаутом",
"hideTimeoutProxiesDesc": "Не показывать узлы, у которых последний тест задержки завершился таймаутом",
"stackMode": "Режим стека",
"network": "Сеть",
"networkDesc": "Настройки, связанные с сетью",
"bypassDomain": "Исключённые домены",
"bypassDomainDesc": "Действует только при включённом системном прокси",
"resetTip": "Вы уверены, что хотите выполнить сброс?",
@@ -284,7 +341,6 @@
"routeModeBypassPrivate": "Обходить частные адреса",
"routeModeConfig": "Использовать конфигурацию",
"routeAddress": "Адреса маршрутов",
"routeAddressDesc": "Настроить прослушиваемые адреса маршрутов",
"interfaceNameMode": "Исходящий интерфейс",
"interfaceNameModeClear": "Очистить",
"interfaceNameModeFollow": "Как в конфигурации",
@@ -293,6 +349,20 @@
"interfaceNameDesc": "Сетевой интерфейс для исходящих соединений",
"copyEnvVar": "Копировать переменные окружения",
"memoryInfo": "Память",
"total": "Всего",
"releaseMemory": "Освободить память",
"memoryCoreHeapInuse": "Используемая куча",
"memoryCoreHeapIdle": "Свободная куча",
"memoryCoreStack": "Стеки горутин",
"memoryCoreRuntime": "Накладные расходы среды",
"memoryCoreNotRunning": "Ядро не запущено",
"memoryAppResident": "Резидентная память",
"memoryAppShared": "Приложение и общая",
"memoryEstimateDesc": "Оценка по резидентной памяти процессов; может отличаться от данных системы.",
"memoryEstimateSharedDesc": "Ядро работает в процессе приложения. Его доля оценивается по статистике среды выполнения, остальное относится к приложению и общей памяти.",
"memoryReleased": "Память освобождена",
"memoryReleasedSize": "Освобождено {size}",
"releaseMemoryFailed": "Не удалось освободить память",
"cancel": "Отмена",
"fileIsUpdate": "Файл изменён. Сохранить изменения?",
"profileHasUpdate": "Профиль изменён. Отключить автообновление?",
@@ -305,27 +375,27 @@
"listen": "Прослушивание",
"undo": "Отменить",
"redo": "Повторить",
"lineWrap": "Перенос строк",
"none": "Нет",
"basicConfig": "Базовая конфигурация",
"basicConfigDesc": "Глобальное изменение базовой конфигурации",
"advancedConfig": "Расширенная конфигурация",
"advancedConfigDesc": "Разнообразные параметры конфигурации",
"advancedConfigDesc": "Сеть, DNS, добавленные правила и скрипты",
"selectedCountTitle": "Выбрано: {count}",
"addRule": "Добавить правило",
"ruleName": "Название правила",
"content": "Содержимое",
"subRule": "Подправило",
"ruleTarget": "Цель правила",
"matchTargetTitle": "Цель MATCH",
"matchTarget": "MATCH-TARGET",
"followProfile": "Как в профиле",
"matchTargetDesc": "Куда направляются правила с целью MATCH-TARGET. По умолчанию — цель последнего правила MATCH этого профиля.",
"selectMatchTarget": "Выбрать MATCH-TARGET",
"sourceIp": "IP источника",
"noResolve": "Не разрешать IP",
"saveChanges": "Сохранить изменения?",
"findProcessModeDesc": "При включении возможна небольшая потеря производительности",
"tabAnimationDesc": "Действует только в мобильном виде",
"navigationBarStyle": "Нижняя панель",
"floating": "Плавающая",
"docked": "Закреплённая",
"slide": "Сдвиг",
"fade": "Растворение",
"pureBlack": "Чисто чёрный",
"colorSchemes": "Цветовые схемы",
"palette": "Палитра",
"tonalSpotScheme": "Тональный акцент",
@@ -348,6 +418,8 @@
"launchInterruptedTip": "В прошлый раз приложение неожиданно завершилось во время запуска. Автоматическая настройка для этого запуска пропущена; вы можете запустить её вручную.",
"clearData": "Очистить данные",
"textScale": "Масштаб текста",
"textScalePreview": "Так будет выглядеть текст в приложении",
"followSystem": "Как в системе",
"internet": "Интернет",
"systemApp": "Системные приложения",
"noNetworkApp": "Приложения без сети",
@@ -361,7 +433,13 @@
"interval": "Интервал",
"existsTip": "«{label}» уже существует",
"maxLengthTip": "«{label}» — не более {max} символов",
"multipleValuesTip": "Разделяйте несколько значений запятыми",
"singleAdd": "По одному",
"batchAdd": "Массовое добавление",
"batchListInputTip": "По одному значению на строку или через запятую",
"batchMapInputTip": "По одной записи на строку: ключ, пробел, значение",
"batchPreviewTip": "Будет добавлено: {count}, пропущено (уже есть): {skipped}",
"lineIssueTip": "Строка {line}: {message}",
"singleValueTip": "«{label}» — только одно значение",
"deleteTip": "Вы уверены, что хотите удалить «{label}»?",
"deleteMultipTip": "Вы уверены, что хотите удалить выбранные элементы ({label})?",
"nullTip": "Пока нет: {label}",
@@ -378,6 +456,7 @@
"import": "Импорт",
"importFile": "Импорт из файла",
"importUrl": "Импорт по URL",
"startFromScratch": "С нуля",
"autoSetSystemDns": "Автонастройка системного DNS",
"details": "Сведения: {label}",
"creationTime": "Время создания",
@@ -396,7 +475,7 @@
"request": "Запрос",
"connected": "Подключено",
"disconnected": "Отключено",
"connecting": "Подключение...",
"connecting": "Подключение…",
"restartCoreTip": "Вы уверены, что хотите перезапустить ядро?",
"forceRestartCoreTip": "Вы уверены, что хотите принудительно перезапустить ядро?",
"dnsHijacking": "Перехват DNS",
@@ -406,20 +485,25 @@
"crashlytics": "Аналитика сбоев",
"crashlyticsTip": "При включении в случае сбоя приложения автоматически загружаются логи сбоя без конфиденциальной информации",
"appendSystemDns": "Добавлять системный DNS",
"appendSystemDnsTip": "Принудительно добавлять системный DNS в конфигурацию",
"editRule": "Редактировать правило",
"overrideMode": "Режим переопределения",
"standardModeDesc": "Стандартный режим: переопределяет базовую конфигурацию и позволяет просто добавлять правила",
"scriptModeDesc": "Режим скрипта: использует внешние скрипты-расширения для переопределения конфигурации в один клик",
"addedRules": "Добавленные правила",
"controlGlobalAddedRules": "Управление глобальными добавленными правилами",
"quickAdd": "Быстрое добавление",
"rulePresetBlockQuic": "Блокировать QUIC",
"rulePresetBlockStun": "Блокировать STUN",
"rulePresetBlockDot": "Блокировать DNS over TLS",
"rulePresetSystemServicesDirect": "Apple и Microsoft напрямую",
"rulePresetBittorrentDirect": "BitTorrent напрямую",
"rulePresetLanDirect": "Локальная сеть напрямую",
"overrideScript": "Скрипт переопределения",
"goToConfigureScript": "Перейти к настройке скрипта",
"editGlobalRules": "Редактировать глобальные правила",
"externalFetch": "Внешнее получение",
"confirmForceCrashCore": "Вы уверены, что хотите принудительно завершить ядро со сбоем?",
"confirmClearAllData": "Вы уверены, что хотите удалить все данные?",
"loading": "Загрузка...",
"loading": "Загрузка…",
"yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} many{{count} лет назад} other{{count} года назад}}",
"monthsAgo": "{count, plural, one{{count} месяц назад} few{{count} месяца назад} many{{count} месяцев назад} other{{count} месяца назад}}",
"daysAgo": "{count, plural, one{{count} день назад} few{{count} дня назад} many{{count} дней назад} other{{count} дня назад}}",
@@ -435,24 +519,31 @@
"speedStatistics": "Статистика скорости",
"resetPageChangesTip": "На этой странице есть изменения. Вы уверены, что хотите выполнить сброс?",
"overwriteTypeCustom": "Пользовательский",
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка групп прокси и правил",
"overwriteTypeCustomDesc": "Пользовательский режим: полная настройка прокси, групп прокси и правил",
"unknownNetworkError": "Неизвестная сетевая ошибка",
"restoreException": "Ошибка восстановления",
"networkException": "Ошибка сети. Проверьте подключение и повторите попытку",
"backupFromNewerVersion": "Резервная копия создана более новой версией приложения. Обновите приложение перед восстановлением",
"networkTimeoutError": "Время ожидания запроса истекло. Проверьте сеть или прокси и повторите попытку",
"networkHostLookupError": "Не удалось определить адрес сервера. Проверьте правильность URL и работу DNS",
"networkConnectionError": "Не удалось подключиться к серверу. Проверьте подключение к сети или настройки прокси",
"networkTlsError": "Не удалось установить защищённое соединение. Сертификат сервера может быть недействителен, или соединение перехватывается",
"networkCancelledError": "Запрос отменён",
"networkAccessDeniedError": "Сервер запретил доступ (HTTP {code}). Возможно, ссылка устарела или учётные данные неверны",
"networkNotFoundError": "По этому адресу ничего не найдено (HTTP {code}). Проверьте правильность URL",
"networkRateLimitedError": "Слишком много запросов (HTTP 429). Подождите немного и повторите попытку",
"networkServerError": "На сервере произошла ошибка (HTTP {code}). Повторите попытку позже",
"networkBadResponseError": "Сервер отклонил запрос (HTTP {code})",
"networkRequestFailed": "Сетевой запрос не выполнен: {detail}",
"invalidBackupFile": "Недопустимый файл резервной копии",
"pruneCache": "Очистить кэш",
"backupAndRestore": "Резервное копирование и восстановление",
"backupAndRestoreDesc": "Синхронизация данных через WebDAV или файлы",
"restore": "Восстановить",
"restoreSuccess": "Восстановление выполнено",
"restoreFromWebDAVDesc": "Восстановить данные из WebDAV",
"restoreFromFileDesc": "Восстановить данные из файла",
"restoreOnlyConfig": "Восстановить только профили",
"restoreAllData": "Восстановить все данные",
"addProfile": "Добавить профиль",
"delayTest": "Тест задержки",
"proxyGroupEmpty": "Группа прокси пуста",
"proxyGroupNameEmpty": "Название группы прокси не может быть пустым",
"proxyGroupNameDuplicate": "Название группы прокси уже используется",
"confirmExitWindow": "Вы уверены, что хотите закрыть текущее окно?",
"dataChangedSave": "Обнаружены изменения данных. Сохранить их?",
@@ -464,6 +555,8 @@
"excludeProxyFilter": "Фильтр исключения узлов",
"excludeType": "Исключаемые типы",
"expectedStatus": "Ожидаемый статус",
"strategy": "Стратегия",
"tolerance": "Допуск",
"selectProxies": "Выбрать прокси",
"inputProxyGroupName": "Введите название группы прокси",
"helperCorruptTip": "Служба Helper недоступна, поэтому TUN-режим включить нельзя. Переустановите FlClash.",
@@ -488,7 +581,7 @@
"basicStrategy": "Базовые политики",
"editProxy": "Редактировать прокси",
"includeAllProxyProviders": "Включить всех провайдеров прокси",
"includeAllProxyProvidersTip": "При включении переопределяет подключённых провайдеров прокси",
"includeAllProxyProvidersTip": "При включении группа берёт всех провайдеров прокси этого профиля: собственных провайдеров подписки, а также профили и прокси-провайдеры приложения, которые использует любая группа прокси",
"addProxyProviders": "Добавить провайдеров прокси",
"includeAllProxies": "Включить все прокси",
"includeAllProxiesTip": "Подключает все прокси вне групп; ниже можно добавить дополнительные группы прокси",
@@ -550,17 +643,16 @@
"invalidSubRule": "{subRule} — недопустимый SUB_RULE",
"invalidPolicy": "{target} — недопустимая политика",
"invalidProxyProvider": "{providerName} — недопустимый провайдер прокси",
"invalidRuleSet": "{ruleSet} — недопустимый набор правил",
"invalidNetworkContent": "Поддерживаются только tcp и udp",
"invalidRangeContent": "Введите числа или диапазоны, например 80 или 8000-9000, через /",
"invalidDscpContent": "Метка DSCP не может превышать 63",
"invalidProxy": "{proxyName} — недопустимый прокси",
"proxyGroupDetectedAbnormal": "Обнаружены отклонения в текущей группе прокси",
"proxyProviderDetectedAbnormal": "Обнаружены отклонения в выбранных провайдерах прокси",
"proxyDetectedAbnormal": "Обнаружены отклонения в выбранных прокси",
"createProfile": "Создать профиль",
"locationPermissionRequired": "Требуется разрешение на геолокацию",
"locationPermissionGuide": "1. Откройте Системные настройки > Конфиденциальность и безопасность\n2. Выберите Службы геолокации\n3. Найдите и отметьте {appName} в списке\n\nПосле настройки вернитесь в приложение и продолжайте работу. Спасибо за сотрудничество.",
"prerequisites": "Предварительные условия",
"ignoreBatteryOptimization": "Игнорировать оптимизацию батареи",
"batteryOptimizationDesc": "Чтобы приложение работало в фоне, отключите для него оптимизацию батареи. Нажмите, чтобы перейти к настройкам.",
"batteryOptimizationStatusTip": "Из-за системных ограничений во время работы невозможно корректно получить статус оптимизации батареи",
"locationPermission": "Разрешение на геолокацию",
"locationPermissionDesc": "По требованию системы для получения имени сети Wi-Fi необходимо разрешение на геолокацию. На Android выберите «Разрешить всегда», иначе имя сети Wi-Fi нельзя получить, пока приложение в фоне.",
"excludeSsids": "Исключённые SSID",
@@ -573,7 +665,7 @@
"editSsid": "Изменить SSID",
"authorized": "Разрешено",
"tapToAuthorize": "Нажмите, чтобы разрешить",
"suspended": "Приостановлено...",
"suspended": "Приостановлено…",
"geoOptions": "Настройки Geo",
"geoAutoUpdate": "Автообновление",
"geoAutoUpdateInterval": "Интервал автообновления",
@@ -584,7 +676,6 @@
"geoSkipped": "{name}: уже последняя версия",
"geoUpdated": "{name}: обновлено",
"secondsCount": "{count, plural, one{{count} секунда} few{{count} секунды} many{{count} секунд} other{{count} секунды}}",
"entriesCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}",
"proxiesCount": "{count} прокси",
"rulesCount": "{count, plural, one{{count} правило} few{{count} правила} many{{count} правил} other{{count} правила}}",
"changelogBreaking": "Важные изменения",
@@ -600,7 +691,6 @@
"pinWindow": "Закрепить поверх всех окон",
"unpinWindow": "Открепить окно",
"exitFullScreen": "Выйти из полноэкранного режима",
"toggleLabel": "Переключить подписи",
"torch": "Фонарик",
"pickFromAlbum": "Выбрать из галереи",
"blockConnection": "Заблокировать соединение",
@@ -611,11 +701,76 @@
"previousMatch": "Предыдущее совпадение",
"nextMatch": "Следующее совпадение",
"clearSearch": "Очистить поиск",
"noSearchResults": "Ничего не найдено",
"addWidget": "Добавить виджет",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"authorize": "Разрешить",
"installedAppsPermissionRequired": "Требуется разрешение на список приложений",
"installedAppsPermissionDesc": "Эта система не выдаёт список установленных приложений без разрешения. Предоставьте его, чтобы настроить прокси для отдельных приложений.",
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках."
"installedAppsPermissionDeniedMessage": "Разрешение на список приложений отклонено, поэтому установленные приложения недоступны. Предоставьте его вручную в системных настройках.",
"outboundIp": "Исходящий IP",
"ipAddress": "IP-адрес",
"ipQualityLevel": "Уровень",
"ipQualityGood": "Хороший",
"ipQualityNormal": "Обычный",
"ipQualityRisky": "Рискованный",
"ipType": "Тип",
"ipTypeResidential": "Домашний",
"ipTypeMobile": "Мобильная сеть",
"ipTypeBusiness": "Бизнес",
"ipTypeHosting": "Дата-центр",
"ipFlags": "Метки",
"ipFlagProxy": "Прокси",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "Злоупотребления",
"ipOrganization": "Организация",
"ipAsn": "ASN",
"ipQualitySource": "Источник ответа",
"ipQualitySources": "Источники",
"ipQualityFailed": "Не удалось определить тип IP",
"ipQualityRetry": "Проверить снова",
"ipSourceNoType": "Тип не определён",
"ipSourceRateLimited": "Лимит запросов",
"ipSourceIpMismatch": "Другой исходящий IP",
"hideIp": "Скрыть IP",
"editorUnavailable": "Редактор недоступен",
"replace": "Заменить",
"replaceAll": "Заменить все",
"cameraPermissionRequired": "Требуется доступ к камере",
"cameraPermissionDesc": "Разрешите доступ к камере в системных настройках, чтобы сканировать QR-коды, или выберите изображение QR-кода из галереи.",
"cameraUnavailable": "Камера недоступна",
"retry": "Повторить",
"invalidProfileQrcode": "Этот QR-код не содержит ссылку на профиль",
"dnsQueries": "DNS-запросы",
"recordType": "Тип записи",
"answers": "Ответы",
"responseCode": "Код ответа",
"error": "Ошибка",
"initiator": "Инициатор",
"cache": "Кэш",
"proxyNode": "Прокси-узел",
"yes": "Да",
"no": "Нет",
"nonTextProviderFile": "Этот внешний ресурс не является текстовым файлом",
"addCustomProxy": "Добавить прокси",
"proxyDefinition": "Полная конфигурация",
"proxyDefinitionNotMap": "Конфигурация должна быть YAML-словарём с полями name и type",
"customProxiesEmpty": "Пользовательских прокси нет, поэтому используются прокси самого профиля",
"overwriteIssueEmptyName": "Имя не задано",
"overwriteIssueReservedName": "{name} — встроенное имя политики, его нельзя использовать",
"overwriteIssueDuplicateName": "Имя {name} уже занято другим прокси или группой прокси",
"overwriteIssueCoreRejected": "Ядро не может разобрать этот прокси: {message}",
"overwriteIssueMissingProxies": "Эти прокси или политики не существуют: {names}",
"overwriteIssueMissingProviders": "Эти провайдеры прокси не существуют: {names}",
"overwriteIssueNoProxySource": "Не выбраны ни прокси, ни провайдеры прокси, поэтому ядро отклонит эту группу",
"overwriteIssueGroupLoop": "Группы прокси ссылаются друг на друга по кругу: {path}",
"overwriteIssuesSummary": "Проблем: {count}, применение переопределения может завершиться ошибкой",
"overwriteIssueSubscriptionGroupMissingProxies": "Собственные группы прокси профиля ссылаются на прокси, которых больше нет среди пользовательских: {names}",
"overwriteIssueProviderShadowed": "В профиле уже есть провайдер с именем {name}, поэтому провайдер приложения с тем же именем не используется. Переименуйте его",
"providerInUse": "«{label}» всё ещё используется в пользовательских группах прокси или правилах профилей: {profiles}. Сначала уберите его оттуда",
"providerSourceSubscription": "Подписка",
"providerRenameShadowed": "В подписках профилей {profiles} уже есть «{label}», и после переименования они будут использовать его. Выберите другое имя",
"appProviderShadowed": "Одноимённый элемент приложения здесь не используется"
}
+244 -89
View File
@@ -9,19 +9,32 @@
"profiles": "配置",
"tools": "工具",
"logs": "日志",
"logsDesc": "日志捕获记录",
"resources": "资源",
"resourcesDesc": "外部资源相关信息",
"trafficUsage": "流量统计",
"networkSpeed": "网络速度",
"outboundMode": "出站模式",
"networkDetection": "网络检测",
"serviceStatus": "服务状态",
"serviceRestricted": "访问受限",
"serviceAvailable": "可用",
"serviceUnavailable": "不可用",
"serviceFailed": "检测失败",
"serviceDisallowedIsp": "不允许的 ISP",
"serviceBlocked": "已被封禁",
"serviceUnsupportedRegion": "地区不支持",
"serviceOriginalsOnly": "仅限自制内容",
"serviceComingSoon": "即将上线",
"servicePending": "待检测",
"serviceManage": "管理服务",
"serviceCheck": "检测",
"serviceCheckAll": "全部检测",
"serviceCheckedAt": "检测于 {time}",
"upload": "上传",
"download": "下载",
"usedTraffic": "已用流量",
"totalTraffic": "总流量",
"expireTime": "到期时间",
"nullProfileDesc": "没有配置文件,请先添加配置文件",
"nullProfileDesc": "添加一个配置文件后即可开始使用",
"settings": "设置",
"language": "语言",
"defaultText": "默认",
@@ -36,27 +49,22 @@
"themeDesc": "设置深色模式,调整色彩",
"override": "覆写",
"allowLan": "局域网代理",
"allowLanDesc": "允许通过局域网访问代理",
"tun": "虚拟网卡",
"tunDesc": "仅在管理员模式生效",
"minimizeOnExit": "退出时最小化",
"minimizeOnExitDesc": "修改系统默认退出事件",
"autoLaunch": "自启动",
"autoLaunchDesc": "跟随系统自启动",
"silentLaunch": "静默启动",
"silentLaunchDesc": "后台启动",
"silentLaunchDesc": "启动时不显示窗口",
"autoRun": "自动运行",
"autoRunDesc": "应用打开时自动运行",
"logcat": "日志捕获",
"logcatDesc": "禁用将会隐藏日志入口",
"autoCheckUpdate": "自动检查更新",
"autoCheckUpdateDesc": "应用启动时自动检查更新",
"checkCertificate": "校验 TLS 证书",
"checkCertificateDesc": "拒绝不受信任的证书。关闭后订阅和备份将暴露于中间人攻击",
"accessControl": "访问控制",
"accessControlDesc": "配置应用访问代理",
"application": "应用程序",
"applicationDesc": "修改应用程序相关设置",
"edit": "编辑",
"confirm": "确定",
"update": "更新",
@@ -103,12 +111,14 @@
"show": "显示",
"exit": "退出",
"systemProxy": "系统代理",
"safeMode": "安全模式",
"safeModeAppTitle": "{appName}(安全模式)",
"project": "项目",
"core": "内核",
"tabAnimation": "选项卡动画",
"desc": "基于ClashMeta的多平台代理客户端,简单易用,开源无广告。",
"startVpn": "正在启动VPN...",
"stopVpn": "正在停止VPN...",
"startVpn": "正在启动VPN…",
"stopVpn": "正在停止VPN…",
"compatible": "兼容模式",
"notSelectedTip": "当前代理组无法选中",
"changeProxyFailedTip": "切换代理失败,已恢复上一次的选择",
@@ -137,28 +147,26 @@
"country": "区域",
"search": "搜索",
"allowBypass": "允许应用绕过VPN",
"allowBypassDesc": "开启后部分应用可绕过VPN",
"externalController": "外部控制器",
"externalControllerDesc": "开启后将可以通过9090端口控制Clash内核",
"ipv6Desc": "开启后将可以接收IPv6流量",
"app": "应用",
"general": "常规",
"systemProxyDesc": "设置系统代理",
"startupAndBackground": "启动与后台",
"requestsAndUpdates": "请求与更新",
"logsAndDiagnostics": "日志与诊断",
"userAgent": "用户代理",
"unifiedDelay": "统一延迟",
"unifiedDelayDesc": "去除握手等额外延迟",
"tcpConcurrent": "TCP并发",
"tcpConcurrentDesc": "开启后允许TCP并发",
"geodataLoader": "Geo低内存模式",
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
"requests": "请求",
"requestsDesc": "查看最近请求记录",
"recentRequests": "最近请求",
"findProcessMode": "查找进程",
"init": "初始化",
"infiniteTime": "长期有效",
"connections": "连接",
"liveConnections": "实时连接",
"inbound": "入站",
"connectionsDesc": "查看当前连接数据",
"intranetIP": "内网 IP",
"view": "查看",
"cut": "剪切",
@@ -167,7 +175,7 @@
"testUrl": "测速链接",
"sync": "同步",
"exclude": "从最近任务中隐藏",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
"excludeDesc": "应用在后台时,从最近任务中隐藏应用",
"expand": "标准",
"shrink": "紧凑",
"min": "最小",
@@ -182,19 +190,18 @@
"go": "前往",
"externalLink": "外部链接",
"otherContributors": "其他贡献者",
"appIconDesign": "应用图标设计",
"autoCloseConnections": "自动关闭连接",
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
"onlyStatisticsProxy": "仅统计代理",
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
"onlyStatisticsProxy": "仅统计代理流量",
"showNotificationStopAction": "通知栏显示停止按钮",
"showNotificationStopActionDesc": "在常驻通知上显示停止按钮。若系统因此总是展开通知,可关闭",
"pureBlackMode": "纯黑模式",
"sidebarBlur": "侧边栏背景模糊",
"sidebarBlurDesc": "侧边栏透出窗口后方模糊的桌面",
"keepAliveIntervalDesc": "TCP保持活动间隔",
"entries": "个条目",
"local": "本地",
"remote": "远程",
"remoteBackupDesc": "备份数据到WebDAV",
"localBackupDesc": "备份数据到本地",
"mode": "模式",
"time": "时间",
"source": "来源",
@@ -209,72 +216,122 @@
"profilesSort": "配置排序",
"start": "启动",
"stop": "暂停",
"dnsDesc": "更新DNS相关设置",
"key": "键",
"value": "值",
"hostsDesc": "追加Hosts",
"vpnTip": "重启VPN后改变生效",
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
"options": "选项",
"loopback": "回环解锁工具",
"loopbackDesc": "用于UWP回环解锁",
"enabled": "已开启",
"disabled": "已关闭",
"loopback": "UWP 回环解锁",
"providers": "外部资源",
"proxyProviders": "代理集",
"ruleProviders": "规则集",
"appProxyProviders": "应用代理集",
"appRuleProviders": "应用规则集",
"behavior": "行为",
"format": "格式",
"providerUrlTip": "仅支持远程资源",
"subscriptionInfo": "订阅信息",
"overrideDns": "覆写DNS",
"overrideDnsDesc": "开启后将覆盖配置中的DNS选项",
"overrideNtp": "覆写NTP",
"ntpStatusDesc": "从NTP服务器获取时间,而非系统时钟",
"server": "服务器",
"ntpInterval": "同步间隔(分钟)",
"dialerProxy": "拨号代理",
"dialerProxyDesc": "用于连接NTP服务器的出站",
"writeToSystem": "写入系统",
"writeToSystemDesc": "同时设置系统时钟,Android上不生效",
"overrideEntries": "覆写项",
"addOverrideEntry": "添加覆写项",
"discardChanges": "是否放弃更改?",
"quickEdit": "快速编辑",
"status": "状态",
"statusDesc": "关闭后将使用系统DNS",
"preferH3Desc": "优先使用DOH的http/3",
"respectRules": "遵守规则",
"respectRulesDesc": "DNS连接跟随rules,需配置proxy-server-nameserver",
"respectRulesDesc": "DNS连接遵守规则,需配置Proxy Server Nameserver",
"dnsMode": "DNS模式",
"fakeipRange": "Fakeip范围",
"fakeipFilter": "Fakeip过滤",
"defaultNameserver": "默认域名服务器",
"defaultNameserverDesc": "用于解析DNS服务器",
"nameserver": "域名服务器",
"nameserverDesc": "用于解析域名",
"fakeipRange": "Fake-IP范围",
"fakeipFilter": "Fake-IP过滤",
"useHosts": "使用Hosts",
"useSystemHosts": "使用系统Hosts",
"nameserverPolicy": "域名服务器策略",
"nameserverPolicyDesc": "指定对应域名服务器策略",
"proxyNameserver": "代理域名服务器",
"proxyNameserverDesc": "用于解析代理节点的域名",
"fallback": "Fallback",
"fallbackDesc": "一般情况下使用境外DNS",
"fallbackFilter": "Fallback过滤",
"geoipCode": "Geoip代码",
"ipcidr": "IP/掩码",
"domain": "域名",
"listenRoutingMark": "监听路由标记",
"listenRoutingMarkDesc": "仅Linux有效",
"ipv6Timeout": "IPv6超时(毫秒)",
"cacheAlgorithm": "缓存算法",
"cacheMaxSize": "缓存大小",
"fakeipRange6": "Fake-IP范围(IPv6)",
"fakeipFilterMode": "Fake-IP过滤模式",
"fakeipFilterModeDesc": "blacklist排除匹配项,whitelist仅用于匹配项,rule按规则匹配",
"fakeipTtl": "Fake-IP TTL",
"reset": "重置",
"actionView": "显示/隐藏",
"actionStart": "启动/停止",
"actionMode": "切换模式",
"actionProxy": "系统代理",
"actionTun": "虚拟网卡",
"actionRuleMode": "规则模式",
"actionGlobalMode": "全局模式",
"actionDirectMode": "直连模式",
"actionDelayTest": "测试全部延迟",
"actionUpdateProfiles": "更新全部配置",
"disclaimer": "免责声明",
"disclaimerDesc": "本软件仅供学习交流、科研等非商业性质的用途,严禁将本软件用于商业目的。如有任何商业行为,均与本软件无关。",
"disclaimerDesc": "在使用 FlClash(以下简称“本软件”)前,请仔细阅读并充分理解本声明的全部内容。点击“同意”即表示你已阅读、理解并接受以下全部条款;如不同意,请点击“退出”并停止使用本软件。",
"disclaimerSoftwareTitle": "软件性质",
"disclaimerSoftwareContent": "本软件是基于 Clash.Meta(mihomo)内核的开源网络代理客户端,仅提供配置管理、规则分流与流量转发等本地工具功能。\n\n本软件自身不提供任何代理服务器、节点、订阅、机场或网络接入服务,也不与任何此类服务的提供者存在合作、代理或担保关系。",
"disclaimerUsageTitle": "使用限制",
"disclaimerUsageContent": "本软件仅供学习交流、技术研究等非商业用途。严禁将本软件用于任何商业目的,包括但不限于付费分发、捆绑销售、作为商业服务的组成部分或以本软件名义开展经营活动;任何商业行为均与本软件及其开发者无关。\n\n严禁将本软件用于违反你所在国家或地区法律法规的活动,包括但不限于绕过依法设置的网络访问限制、传播违法信息、实施网络攻击或侵犯他人合法权益。",
"disclaimerResponsibilityTitle": "用户责任",
"disclaimerResponsibilityContent": "你应自行确认使用本软件在你所在国家或地区是合法的,并对使用本软件的全部行为及其后果独立承担法律责任。\n\n你导入的订阅、节点与配置由你自行选择和判断,其来源是否合法、内容是否安全、服务是否稳定,均由你与相关提供者自行负责。",
"disclaimerThirdPartyTitle": "第三方内容",
"disclaimerThirdPartyContent": "订阅链接、配置文件、规则集、脚本、外部资源及外部链接均由第三方提供,开发者无法也不会对其合法性、准确性、安全性和可用性进行审查或担保。\n\n因使用第三方内容导致的数据泄露、财产损失、账号封禁或其他任何损失,由你与第三方自行解决,开发者不承担任何责任。",
"disclaimerWarrantyTitle": "无担保声明",
"disclaimerWarrantyContent": "本软件按“现状”和“可用”的原则提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性、不侵权以及持续可用、无错误、无安全漏洞的担保。\n\n开发者不保证本软件能满足你的需求,也不保证本软件的运行不会中断或不会出现错误。",
"disclaimerLiabilityTitle": "责任限制",
"disclaimerLiabilityContent": "在适用法律允许的最大范围内,开发者及所有贡献者均不对因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊、惩罚性或后果性损害承担责任,包括但不限于数据丢失、设备损坏、网络异常、业务中断、利润损失或因此引起的任何法律纠纷,即使已被告知发生此类损害的可能性。",
"disclaimerPrivacyTitle": "数据收集与隐私",
"disclaimerPrivacyContent": "本软件不会收集或上传你的订阅地址、节点信息、配置内容、访问的网站、连接记录、流量内容和日志,这些数据仅保存在你的设备本地,开发者无法获取。\n\n本软件只会在你使用相应功能时访问外部网络,例如更新订阅时访问你提供的订阅地址、检查更新时访问 GitHub。\n\n桌面版(Windows、macOS、Linux)未集成任何数据统计或崩溃上报服务。Android 版集成了 Google Firebase 的以下两项服务,用于改进应用稳定性:",
"disclaimerCrashlyticsTitle": "Firebase Crashlytics(崩溃分析)",
"disclaimerCrashlyticsContent": "应用发生崩溃时,自动上传崩溃报告。\n\n收集内容:崩溃堆栈与错误信息、崩溃发生时间、应用版本与构建号、设备品牌与型号、Android 系统版本、屏幕方向、剩余内存与存储空间、设备是否已 root,以及一个随安装生成、重装后即重置的随机安装标识。\n\n用途:仅用于定位和修复崩溃问题。\n\n你可以在“工具 > 常规 > 崩溃分析”中随时关闭。",
"disclaimerAnalyticsTitle": "Firebase Analytics(使用统计)",
"disclaimerAnalyticsContent": "随 Firebase 自动收集基础的应用使用统计。\n\n收集内容:首次启动、应用打开与会话时长、应用更新等基础事件,应用实例标识,设备型号、系统版本、系统语言,以及由 IP 地址推断的国家或地区级大致位置。\n\n用途:仅用于了解活跃设备数、版本分布与系统兼容情况。开发者不会将这些数据用于广告,不会出售,也不会与你的订阅或配置相关联。",
"disclaimerDataProcessingContent": "上述数据由 Google 代为处理和存储,可能被传输至你所在国家或地区以外(如美国)的服务器,并受 Google 隐私政策与 Firebase 隐私和安全说明约束。崩溃报告最多保留 90 天,统计数据按 Firebase 的默认策略保留。",
"disclaimerLicenseTitle": "开源协议",
"disclaimerLicenseContent": "本软件以 GPL-3.0 协议开源。你可以在遵守该协议的前提下自由使用、修改和分发本软件,但须同样以 GPL-3.0 协议开源并保留原作者的版权声明。\n\n本软件包含的第三方组件(包括 Clash.Meta 内核)分别遵循其各自的开源协议。基于本软件修改或分发的版本所产生的任何问题,与原作者无关。",
"disclaimerChangesTitle": "声明变更",
"disclaimerChangesContent": "开发者有权随版本更新修改本声明,修改后的内容随新版本发布即生效。你在更新后继续使用本软件,即视为接受修改后的声明。",
"disclaimerAcceptTitle": "接受声明",
"disclaimerAcceptContent": "一旦你安装、复制或使用本软件,即视为你已阅读并同意本声明的全部内容。如你不同意本声明的任何条款,请立即停止使用并卸载本软件。",
"disclaimerAndroidOnly": "仅 Android",
"disclaimerGooglePrivacy": "Google 隐私政策",
"disclaimerFirebasePrivacy": "Firebase 隐私和安全说明",
"agree": "同意",
"hotkeyManagement": "快捷键管理",
"hotkeyManagementDesc": "使用键盘控制应用程序",
"pressKeyboard": "请按下按键",
"inputCorrectHotkey": "请输入正确的快捷键",
"hotkeyConflict": "快捷键冲突",
"hotkeyDesc": "全局快捷键在窗口隐藏时依然生效,点击操作即可录制组合键。",
"hotkeyNotSet": "未设置",
"pressKeyboard": "请按下组合键",
"hotkeyNeedsModifier": "至少包含 {modifiers} 中的一个",
"hotkeyConflictWith": "已被“{action}”使用,保存后将移到这里",
"hotkeyUnavailable": "未能注册,可能已被其他应用占用",
"remove": "移除",
"noHotKey": "暂无快捷键",
"noNetwork": "无网络",
"ipv6InboundDesc": "允许IPv6入站",
"exportLogs": "导出日志",
"exportSuccess": "导出成功",
"iconStyle": "图标样式",
"onlyIcon": "仅图标",
"iconStyleFilled": "色块",
"iconStylePlain": "纯图标",
"iconStyleHidden": "隐藏",
"filter": "筛选",
"hideTimeoutProxies": "隐藏超时节点",
"hideTimeoutProxiesDesc": "不显示上次延迟测试超时的节点",
"stackMode": "栈模式",
"network": "网络",
"networkDesc": "修改网络相关设置",
"bypassDomain": "排除域名",
"bypassDomainDesc": "仅在系统代理启用时生效",
"resetTip": "确定要重置吗?",
"resetTip": "确定要重置吗?",
"icon": "图片",
"noData": "暂无数据",
"fontFamily": "字体",
@@ -284,7 +341,6 @@
"routeModeBypassPrivate": "绕过私有路由地址",
"routeModeConfig": "使用配置",
"routeAddress": "路由地址",
"routeAddressDesc": "配置监听路由地址",
"interfaceNameMode": "出站网卡",
"interfaceNameModeClear": "清空",
"interfaceNameModeFollow": "跟随配置",
@@ -293,9 +349,23 @@
"interfaceNameDesc": "出站连接使用的网卡名称",
"copyEnvVar": "复制环境变量",
"memoryInfo": "内存信息",
"total": "总计",
"releaseMemory": "释放内存",
"memoryCoreHeapInuse": "堆内存使用中",
"memoryCoreHeapIdle": "堆内存空闲",
"memoryCoreStack": "协程栈",
"memoryCoreRuntime": "运行时开销",
"memoryCoreNotRunning": "内核未运行",
"memoryAppResident": "常驻内存",
"memoryAppShared": "应用及共享",
"memoryEstimateDesc": "基于进程常驻内存估算,可能与系统显示的数值不同。",
"memoryEstimateSharedDesc": "内核与应用运行在同一进程,内核部分按运行时统计估算,其余计入应用及共享内存。",
"memoryReleased": "内存已释放",
"memoryReleasedSize": "已释放 {size}",
"releaseMemoryFailed": "释放内存失败",
"cancel": "取消",
"fileIsUpdate": "文件有修改,是否保存修改",
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新?",
"hasCacheChange": "是否缓存修改",
"copySuccess": "复制成功",
"copyLink": "复制链接",
@@ -305,27 +375,27 @@
"listen": "监听",
"undo": "撤销",
"redo": "重做",
"lineWrap": "自动换行",
"none": "无",
"basicConfig": "基础配置",
"basicConfigDesc": "全局修改基础配置",
"advancedConfig": "进阶配置",
"advancedConfigDesc": "提供多样化配置",
"advancedConfigDesc": "网络、DNS、附加规则与脚本",
"selectedCountTitle": "已选择 {count} 项",
"addRule": "添加规则",
"ruleName": "规则名称",
"content": "内容",
"subRule": "子规则",
"ruleTarget": "规则目标",
"matchTargetTitle": "匹配目标",
"matchTarget": "MATCH-TARGET",
"followProfile": "跟随配置",
"matchTargetDesc": "目标为 MATCH-TARGET 的规则路由到这里,默认取本配置末尾 MATCH 规则的目标。",
"selectMatchTarget": "选择 MATCH-TARGET",
"sourceIp": "源IP",
"noResolve": "不解析IP",
"saveChanges": "是否保存更改?",
"findProcessModeDesc": "开启后会有一定性能损耗",
"tabAnimationDesc": "仅在移动视图中有效",
"navigationBarStyle": "底栏",
"floating": "悬浮",
"docked": "固定",
"slide": "滑动",
"fade": "淡入",
"pureBlack": "纯黑",
"colorSchemes": "配色方案",
"palette": "调色板",
"tonalSpotScheme": "调性点缀",
@@ -348,6 +418,8 @@
"launchInterruptedTip": "应用上次在启动过程中意外退出。已跳过本次自动配置,你可以手动启动重试。",
"clearData": "清除数据",
"textScale": "文本缩放",
"textScalePreview": "应用内的文字将以这个大小显示",
"followSystem": "跟随系统",
"internet": "互联网",
"systemApp": "系统应用",
"noNetworkApp": "无网络应用",
@@ -361,7 +433,13 @@
"interval": "间隔",
"existsTip": "{label}当前已存在",
"maxLengthTip": "{label}最多{max}个字符",
"multipleValuesTip": "多个值使用逗号分隔",
"singleAdd": "单条添加",
"batchAdd": "批量添加",
"batchListInputTip": "每行一项,也可用逗号分隔",
"batchMapInputTip": "每行一条,键和值之间用空格分隔",
"batchPreviewTip": "将添加 {count} 项,跳过 {skipped} 项已存在",
"lineIssueTip": "第 {line} 行:{message}",
"singleValueTip": "{label}只能是一项",
"deleteTip": "确定删除当前{label}吗?",
"deleteMultipTip": "确定删除选中的{label}吗?",
"nullTip": "暂无{label}",
@@ -378,6 +456,7 @@
"import": "导入",
"importFile": "通过文件导入",
"importUrl": "通过URL导入",
"startFromScratch": "从零开始",
"autoSetSystemDns": "自动设置系统DNS",
"details": "{label}详情",
"creationTime": "创建时间",
@@ -396,7 +475,7 @@
"request": "请求",
"connected": "已连接",
"disconnected": "已断开",
"connecting": "连接中...",
"connecting": "连接中…",
"restartCoreTip": "您确定要重启核心吗?",
"forceRestartCoreTip": "您确定要强制重启核心吗?",
"dnsHijacking": "DNS劫持",
@@ -406,20 +485,25 @@
"crashlytics": "崩溃分析",
"crashlyticsTip": "开启后,应用崩溃时自动上传不包含敏感信息的崩溃日志",
"appendSystemDns": "追加系统DNS",
"appendSystemDnsTip": "强制为配置附加系统DNS",
"editRule": "编辑规则",
"overrideMode": "覆写模式",
"standardModeDesc": "标准模式,覆写基础配置,提供简单追加规则能力",
"scriptModeDesc": "脚本模式,使用外部扩展脚本,提供一键覆写配置的能力",
"addedRules": "附加规则",
"controlGlobalAddedRules": "控制全局附加规则",
"quickAdd": "快捷添加",
"rulePresetBlockQuic": "屏蔽 QUIC",
"rulePresetBlockStun": "屏蔽 STUN",
"rulePresetBlockDot": "屏蔽 DNS over TLS",
"rulePresetSystemServicesDirect": "Apple 与 Microsoft 直连",
"rulePresetBittorrentDirect": "BT 下载直连",
"rulePresetLanDirect": "局域网直连",
"overrideScript": "覆写脚本",
"goToConfigureScript": "前往配置脚本",
"editGlobalRules": "编辑全局规则",
"externalFetch": "外部获取",
"confirmForceCrashCore": "确定要强制崩溃核心?",
"confirmClearAllData": "确定要清除所有数据?",
"loading": "加载中...",
"loading": "加载中…",
"yearsAgo": "{count} 年前",
"monthsAgo": "{count} 个月前",
"daysAgo": "{count} 天前",
@@ -435,26 +519,33 @@
"speedStatistics": "网速统计",
"resetPageChangesTip": "当前页面存在更改,确定重置吗?",
"overwriteTypeCustom": "自定义",
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理组以及规则",
"overwriteTypeCustomDesc": "自定义模式,支持完全自定义修改代理、策略组以及规则",
"unknownNetworkError": "未知网络错误",
"restoreException": "恢复异常",
"networkException": "网络异常,请检查连接后重试",
"backupFromNewerVersion": "该备份来自更高版本的应用,请先更新应用再恢复",
"networkTimeoutError": "请求超时,请检查网络或代理后重试",
"networkHostLookupError": "无法解析服务器地址,请确认链接正确且 DNS 可用",
"networkConnectionError": "无法连接到服务器,请检查网络连接或代理设置",
"networkTlsError": "安全连接失败,服务器证书可能无效,或连接被拦截",
"networkCancelledError": "请求已取消",
"networkAccessDeniedError": "服务器拒绝访问(HTTP {code}),链接可能已过期或凭据有误",
"networkNotFoundError": "该地址下没有内容(HTTP {code}),请确认链接是否正确",
"networkRateLimitedError": "请求过于频繁(HTTP 429),请稍后再试",
"networkServerError": "服务器出现问题(HTTP {code}),请稍后再试",
"networkBadResponseError": "服务器拒绝了请求(HTTP {code})",
"networkRequestFailed": "网络请求失败:{detail}",
"invalidBackupFile": "无效备份文件",
"pruneCache": "修剪缓存",
"backupAndRestore": "备份与恢复",
"backupAndRestoreDesc": "通过WebDAV或者文件同步数据",
"restore": "恢复",
"restoreSuccess": "恢复成功",
"restoreFromWebDAVDesc": "通过WebDAV恢复数据",
"restoreFromFileDesc": "通过文件恢复数据",
"restoreOnlyConfig": "仅恢复配置文件",
"restoreAllData": "恢复所有数据",
"addProfile": "添加配置",
"delayTest": "延迟测试",
"proxyGroupEmpty": "策略组为空",
"proxyGroupNameEmpty": "策略组名称不能为空",
"proxyGroupNameDuplicate": "策略组名称重复",
"confirmExitWindow": "确定要退出当前窗口吗?",
"confirmExitWindow": "确定要退出当前窗口吗?",
"dataChangedSave": "检测到数据有更改,是否保存",
"selectProxyProviders": "选择代理集",
"proxyFilter": "节点过滤器",
@@ -464,6 +555,8 @@
"excludeProxyFilter": "排除节点过滤器",
"excludeType": "排除类型",
"expectedStatus": "预期状态",
"strategy": "策略",
"tolerance": "容差",
"selectProxies": "选择代理",
"inputProxyGroupName": "输入策略组名称",
"helperCorruptTip": "Helper 服务不可用,无法启用 TUN 模式,请重新安装 FlClash。",
@@ -488,7 +581,7 @@
"basicStrategy": "基础策略",
"editProxy": "编辑代理",
"includeAllProxyProviders": "包含所有代理集",
"includeAllProxyProvidersTip": "开启后将覆盖引入的代理集",
"includeAllProxyProvidersTip": "开启后包含本配置的全部代理集:订阅自带的,以及任一策略组引用的配置和应用代理集",
"addProxyProviders": "添加代理集",
"includeAllProxies": "包含所有代理",
"includeAllProxiesTip": "引入不包含策略组的所有代理,可在下方额外添加策略组",
@@ -510,7 +603,7 @@
"ruleActionDomainWildcardDesc": "通配符匹配,仅支持*和?通配符",
"ruleActionGeositeDesc": "匹配 Geosite 内的域名",
"ruleActionIpCidrDesc": "匹配 IP 地址范围",
"ruleActionIpCidr6Desc": "匹配 IP 地址范围, IP-CIDR6 只是一个别名",
"ruleActionIpCidr6Desc": "匹配 IP 地址范围,IP-CIDR6 只是一个别名",
"ruleActionIpSuffixDesc": "匹配 IP 后缀范围",
"ruleActionIpAsnDesc": "匹配 IP 所属 ASN",
"ruleActionGeoipDesc": "匹配 IP 所属国家代码",
@@ -533,12 +626,12 @@
"ruleActionProcessNameWildcardDesc": "使用进程名称通配符匹配,仅支持*和?通配符",
"ruleActionUidDesc": "匹配 Linux USER ID",
"ruleActionNetworkDesc": "匹配TCP或者UDP",
"ruleActionDscpDesc": "匹配DSCP标记 (仅限 tproxy udp 入站)",
"ruleActionDscpDesc": "匹配DSCP标记(仅限 tproxy udp 入站)",
"ruleActionRuleSetDesc": "引用规则集合,需配置rule-providers",
"ruleActionAndDesc": "逻辑规则 AND",
"ruleActionOrDesc": "逻辑规则 OR",
"ruleActionNotDesc": "逻辑规则 NOT",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionSubRuleDesc": "匹配至子规则,需要注意括号的使用",
"ruleActionMatchDesc": "匹配所有请求,无需条件",
"subRuleEmpty": "子规则为空",
"proxyProvidersNotEmpty": "代理集不能为空",
@@ -550,17 +643,16 @@
"invalidSubRule": "{subRule} 是一个无效的SUB_RULE",
"invalidPolicy": "{target} 是一个无效的策略",
"invalidProxyProvider": "{providerName} 是一个无效的代理集",
"invalidRuleSet": "{ruleSet} 不是有效的规则集",
"invalidNetworkContent": "仅支持 tcp 或 udp",
"invalidRangeContent": "请输入数字或范围,如 80 或 8000-9000,多个用 / 分隔",
"invalidDscpContent": "DSCP 标记不能超过 63",
"invalidProxy": "{proxyName} 是一个无效的代理",
"proxyGroupDetectedAbnormal": "检测到当前策略组异常",
"proxyProviderDetectedAbnormal": "检测到选中的代理集存在异常",
"proxyDetectedAbnormal": "检测到选中的代理存在异常",
"createProfile": "创建配置",
"locationPermissionRequired": "需要位置权限",
"locationPermissionGuide": "1. 打开 系统设置 > 隐私与安全性\n2. 选择 定位服务\n3. 在右侧列表中找到并勾选 {appName}\n\n完成设置后,返回应用即可正常使用。感谢您的配合。",
"prerequisites": "前置条件",
"ignoreBatteryOptimization": "忽略电池优化",
"batteryOptimizationDesc": "为保证后台运行,请关闭本应用的电池优化。点击前往设置。",
"batteryOptimizationStatusTip": "由于系统限制,运行状态下无法正确获取电池优化状态",
"locationPermission": "位置权限",
"locationPermissionDesc": "根据系统要求,获取Wi-Fi名称需要您授予位置权限。Android 上请选择“始终允许”,否则应用在后台时无法获取 Wi-Fi 名称。",
"excludeSsids": "排除SSIDs",
@@ -573,7 +665,7 @@
"editSsid": "编辑SSID",
"authorized": "已授权",
"tapToAuthorize": "点击授权",
"suspended": "挂起中...",
"suspended": "挂起中…",
"geoOptions": "Geo 选项",
"geoAutoUpdate": "自动更新",
"geoAutoUpdateInterval": "自动更新间隔",
@@ -584,7 +676,6 @@
"geoSkipped": "{name} 已是最新版本",
"geoUpdated": "{name} 已更新",
"secondsCount": "{count} 秒",
"entriesCount": "{count} 个条目",
"proxiesCount": "{count} 个代理",
"rulesCount": "{count} 条规则",
"changelogBreaking": "重大变更",
@@ -600,7 +691,6 @@
"pinWindow": "窗口置顶",
"unpinWindow": "取消置顶",
"exitFullScreen": "退出全屏",
"toggleLabel": "切换标签",
"torch": "手电筒",
"pickFromAlbum": "从相册选择",
"blockConnection": "阻止连接",
@@ -611,11 +701,76 @@
"previousMatch": "上一个匹配",
"nextMatch": "下一个匹配",
"clearSearch": "清除搜索",
"noSearchResults": "没有匹配的结果",
"addWidget": "添加组件",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"authorize": "授权",
"installedAppsPermissionRequired": "需要读取应用列表权限",
"installedAppsPermissionDesc": "当前系统在授权前不会提供已安装的应用列表,授权后即可配置分应用代理。",
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。"
"installedAppsPermissionDeniedMessage": "读取应用列表权限已被拒绝,无法获取已安装的应用。请前往系统设置手动开启。",
"outboundIp": "出站 IP",
"ipAddress": "IP 地址",
"ipQualityLevel": "等级",
"ipQualityGood": "优",
"ipQualityNormal": "普通",
"ipQualityRisky": "风险",
"ipType": "类型",
"ipTypeResidential": "住宅",
"ipTypeMobile": "移动网络",
"ipTypeBusiness": "商业",
"ipTypeHosting": "机房",
"ipFlags": "命中标记",
"ipFlagProxy": "代理",
"ipFlagVpn": "VPN",
"ipFlagTor": "Tor",
"ipFlagAbuser": "滥用记录",
"ipOrganization": "组织",
"ipAsn": "ASN",
"ipQualitySource": "采用来源",
"ipQualitySources": "各来源",
"ipQualityFailed": "类型查询失败",
"ipQualityRetry": "重新查询",
"ipSourceNoType": "无法判定类型",
"ipSourceRateLimited": "限流",
"ipSourceIpMismatch": "出站 IP 不一致",
"hideIp": "隐藏 IP",
"editorUnavailable": "编辑器不可用",
"replace": "替换",
"replaceAll": "全部替换",
"cameraPermissionRequired": "需要相机权限",
"cameraPermissionDesc": "请在系统设置中允许访问相机以扫描二维码,或从相册选择二维码图片。",
"cameraUnavailable": "相机不可用",
"retry": "重试",
"invalidProfileQrcode": "该二维码不包含配置文件链接",
"dnsQueries": "DNS查询",
"recordType": "记录类型",
"answers": "应答",
"responseCode": "响应码",
"error": "错误",
"initiator": "发起方",
"cache": "缓存",
"proxyNode": "代理节点",
"yes": "是",
"no": "否",
"nonTextProviderFile": "该外部资源不是文本文件",
"addCustomProxy": "添加代理",
"proxyDefinition": "完整配置",
"proxyDefinitionNotMap": "配置必须是包含 name 和 type 的 YAML 映射",
"customProxiesEmpty": "未添加自定义代理,沿用配置文件自身的代理",
"overwriteIssueEmptyName": "名称为空",
"overwriteIssueReservedName": "{name} 是内置策略名,不能在此使用",
"overwriteIssueDuplicateName": "名称 {name} 已被其他代理或策略组使用",
"overwriteIssueCoreRejected": "内核无法解析该代理:{message}",
"overwriteIssueMissingProxies": "以下代理或策略不存在:{names}",
"overwriteIssueMissingProviders": "以下代理集不存在:{names}",
"overwriteIssueNoProxySource": "未选择任何代理或代理集,内核会拒绝该策略组",
"overwriteIssueGroupLoop": "策略组之间存在循环引用:{path}",
"overwriteIssuesSummary": "{count} 项存在问题,应用覆写可能失败",
"overwriteIssueSubscriptionGroupMissingProxies": "配置文件自身的策略组引用了自定义代理中已没有的代理:{names}",
"overwriteIssueProviderShadowed": "配置文件中已有名为 {name} 的外部资源,同名的应用级外部资源不会生效,请改名",
"providerInUse": "{label} 仍被 {profiles} 的自定义策略组或规则使用,请先在那里移除",
"providerSourceSubscription": "订阅",
"providerRenameShadowed": "{profiles} 的订阅里已有 {label},改名后这些配置会改用订阅里的那个,请换一个名称",
"appProviderShadowed": "同名的应用级项在此不生效"
}
+2 -1
View File
@@ -16,6 +16,7 @@ import 'package:fl_clash/l10n/l10n.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/views/disclaimer.dart';
import 'package:fl_clash/views/navigation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
@@ -241,7 +242,7 @@ class Bootstrap {
)) {
return;
}
final isDisclaimerAccepted = await dialogs.showDisclaimer();
final isDisclaimerAccepted = await requestDisclaimerConsent();
if (!isDisclaimerAccepted) {
await _container.read(systemActionProvider.notifier).handleExit();
}
+1
View File
@@ -44,6 +44,7 @@ export 'provider_reader.dart';
export 'proxy.dart';
export 'request.dart';
export 'scroll.dart';
export 'search.dart';
export 'shape.dart';
export 'snowflake.dart';
export 'string.dart';
+64 -17
View File
@@ -16,23 +16,18 @@ List<Group> computeSort({
required Map<String, String> selectedMap,
required String testUrl,
}) {
return List.from(proxies)..sort((a, b) {
final aDelayState = computeProxyDelayState(
proxyName: a.name,
testUrl: testUrl,
groups: groups,
selectedMap: selectedMap,
delayMap: delayMap,
);
final bDelayState = computeProxyDelayState(
proxyName: b.name,
testUrl: testUrl,
groups: groups,
selectedMap: selectedMap,
delayMap: delayMap,
);
return aDelayState.compareTo(bDelayState);
});
final delayStates = {
for (final proxy in proxies)
proxy.name: computeProxyDelayState(
proxyName: proxy.name,
testUrl: testUrl,
groups: groups,
selectedMap: selectedMap,
delayMap: delayMap,
),
};
return List.of(proxies)
..sort((a, b) => delayStates[a.name]!.compareTo(delayStates[b.name]!));
}
List<Proxy> sortOfName(List<Proxy> proxies) {
@@ -56,6 +51,58 @@ List<Group> computeSort({
}).toList();
}
/// Built-in adapters whose delay probe always fails, so a timeout says nothing.
const _unprobeableProxyTypes = {
'Reject',
'RejectDrop',
'Pass',
'PassRule',
'Rematch',
'Compatible',
'Dns',
};
List<Group> computeHideTimeout({
required List<Group> groups,
required List<Group> allGroups,
required DelayMap delayMap,
required Map<String, String> selectedMap,
required String defaultTestUrl,
}) {
final realStates = <String, SelectedProxyState>{};
final proxyTypes = {
for (final group in allGroups)
for (final proxy in group.all) proxy.name: proxy.type,
};
return groups.map((group) {
final groupTestUrl = group.testUrl.takeFirstValid([defaultTestUrl]);
final groupWithNow = allGroups.getGroup(group.name) ?? group;
final selectedName = groupWithNow.getCurrentSelectedName(
selectedMap[group.name] ?? '',
);
final visible = group.all.where((proxy) {
if (proxy.name == selectedName) {
return true;
}
final state = realStates.putIfAbsent(
proxy.name,
() => computeRealSelectedProxyState(
proxy.name,
groups: allGroups,
selectedMap: selectedMap,
),
);
if (_unprobeableProxyTypes.contains(proxyTypes[state.proxyName])) {
return true;
}
final testUrl = state.testUrl.takeFirstValid([groupTestUrl]);
final delay = delayMap[testUrl]?[state.proxyName];
return delay == null || delay > 0;
}).toList();
return group.copyWith(all: visible.isEmpty ? group.all : visible);
}).toList();
}
SelectedProxyState getRealSelectedProxyState(
SelectedProxyState state, {
required List<Group> groups,
+5
View File
@@ -83,6 +83,11 @@ const maxConcurrentDelayTests = 16;
const animateDuration = Duration(milliseconds: 100);
const midDuration = Duration(milliseconds: 200);
const commonDuration = Duration(milliseconds: 300);
/// How often a live Core feed is allowed to repaint. One batch costs about a
/// frame on a phone, and anything at or below the 200ms scroll-to-end
/// animation restarts it mid-flight, so the list jumps instead of animating.
const renderThrottleDuration = Duration(milliseconds: 300);
const defaultUpdateDuration = Duration(days: 1);
const MMDB = 'GEOIP.metadb';
const ASN = 'ASN.mmdb';
+3 -3
View File
@@ -45,15 +45,15 @@ extension DateTimeExtension on DateTime {
}
String get show {
return toString().substring(0, 10);
return toLocal().toString().substring(0, 10);
}
String get showFull {
return toString().substring(0, 19);
return toLocal().toString().substring(0, 19);
}
String get showTime {
return toString().substring(10, 19);
return toLocal().toString().substring(10, 19);
}
}
+29 -6
View File
@@ -5,9 +5,35 @@ import 'package:flutter/foundation.dart';
import 'print.dart';
import 'protocol.dart';
import 'string.dart';
typedef InstallConfigCallBack = void Function(String url);
String? profileUrlFromQrCodes(Iterable<String?> values) {
for (final value in values) {
final text = value?.trim();
if (text == null || text.isEmpty) {
continue;
}
if (text.isUrl) {
return text;
}
final uri = Uri.tryParse(text);
if (uri == null || !protocolSchemes.contains(uri.scheme)) {
continue;
}
final url = _installConfigUrl(uri)?.trim();
if (url != null && url.isUrl) {
return url;
}
}
return null;
}
String? _installConfigUrl(Uri uri) {
return uri.host == 'install-config' ? uri.queryParameters['url'] : null;
}
class LinkManager {
static LinkManager? _instance;
StreamSubscription? subscription;
@@ -46,12 +72,9 @@ class LinkManager {
void _handle(Uri uri, Function(String url) installConfigCallBack) {
commonPrint.log('onAppLink: $uri');
if (uri.host == 'install-config') {
final parameters = uri.queryParameters;
final url = parameters['url'];
if (url != null) {
installConfigCallBack(url);
}
final url = _installConfigUrl(uri);
if (url != null) {
installConfigCallBack(url);
}
}
+9 -6
View File
@@ -44,16 +44,19 @@ class Picker {
if (xFile == null) {
return null;
}
final controller = MobileScannerController();
final capture = await controller.analyzeImage(
// Not through a throwaway MobileScannerController: disposing one clears
// the platform scan window of a scanner page that is still open.
final capture = await MobileScannerPlatform.instance.analyzeImage(
xFile.path,
formats: [BarcodeFormat.qrCode],
formats: const [BarcodeFormat.qrCode],
);
final result = capture?.barcodes.first.rawValue;
if (result == null || !result.isUrl) {
final url = profileUrlFromQrCodes(
capture?.barcodes.map((barcode) => barcode.rawValue) ?? const [],
);
if (url == null) {
throw MessageException(currentAppLocalizations.pleaseUploadValidQrcode);
}
return result;
return url;
}
}
+12 -13
View File
@@ -5,6 +5,15 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/widgets/scroll.dart';
import 'package:material_ui/material_ui.dart';
bool isTouchPlatform(TargetPlatform platform) => switch (platform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.fuchsia => true,
TargetPlatform.linux ||
TargetPlatform.macOS ||
TargetPlatform.windows => false,
};
class BaseScrollBehavior extends MaterialScrollBehavior {
const BaseScrollBehavior({this.scrollbarPadding = EdgeInsets.zero});
@@ -20,18 +29,8 @@ class BaseScrollBehavior extends MaterialScrollBehavior {
PointerDeviceKind.unknown,
};
bool showScrollbar(BuildContext context) {
switch (getPlatform(context)) {
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
return true;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return false;
}
}
bool showScrollbar(BuildContext context) =>
!isTouchPlatform(getPlatform(context));
@override
Widget buildScrollbar(
@@ -43,7 +42,7 @@ class BaseScrollBehavior extends MaterialScrollBehavior {
!showScrollbar(context)) {
return child;
}
return CommonScrollBar(
return CommonScrollBar.ambient(
controller: details.controller,
padding: scrollbarPadding,
child: child,
+59
View File
@@ -0,0 +1,59 @@
final _termSeparator = RegExp(r'\s+');
class SearchQuery {
final List<String> terms;
SearchQuery(String text)
: terms = text
.toLowerCase()
.split(_termSeparator)
.where((term) => term.isNotEmpty)
.toList(growable: false);
bool get isEmpty => terms.isEmpty;
bool get isNotEmpty => terms.isNotEmpty;
// Terms hold no whitespace, so the newline keeps one from spanning fields.
static String textOf(Iterable<String?> fields) {
return fields.nonNulls.join('\n').toLowerCase();
}
bool matches(Iterable<String?> fields) {
if (terms.isEmpty) {
return true;
}
return matchesText(textOf(fields));
}
bool matchesText(String text) => terms.every(text.contains);
}
extension SearchIterableExt<T> on Iterable<T> {
/// [texts] caches each item's text by identity, so it serves one [fieldsOf].
Iterable<T> whereMatches(
SearchQuery query,
Iterable<String?> Function(T item) fieldsOf, {
Expando<String>? texts,
}) {
if (query.isEmpty) {
return this;
}
return where((item) {
if (texts == null || !_isExpandoKey(item)) {
return query.matches(fieldsOf(item));
}
return query.matchesText(
texts[item as Object] ??= SearchQuery.textOf(fieldsOf(item)),
);
});
}
}
bool _isExpandoKey(Object? item) {
return item != null &&
item is! String &&
item is! num &&
item is! bool &&
item is! Record;
}
+2
View File
@@ -13,6 +13,8 @@ class TaskPool {
int get pendingCount => _waiting.length;
int get idleSlots => _waiting.isEmpty ? concurrency - _active : 0;
Future<T> run<T>(Future<T> Function() task) async {
if (_active >= concurrency || _waiting.isNotEmpty) {
final waiter = Completer<void>();
+42 -8
View File
@@ -108,6 +108,8 @@ extension GroupTypeExtension on GroupType {
enum UsedProxy { GLOBAL, DIRECT, REJECT }
enum DelayTestPhase { queued, running }
extension UsedProxyExtension on UsedProxy {
static List<String> get valueList =>
UsedProxy.values.map((e) => e.toString().split('.').last).toList();
@@ -121,15 +123,46 @@ enum ViewMode { mobile, laptop, desktop }
enum LogLevel { debug, info, warning, error, silent }
extension LogLevelExt on LogLevel {
Color? color(BuildContext context) {
enum RecordTone { muted, neutral, warning, error }
extension RecordToneExt on RecordTone {
Color? accentColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
LogLevel.silent => colorScheme.outline,
LogLevel.debug => colorScheme.onSurfaceVariant,
LogLevel.info => null,
LogLevel.warning => colorScheme.tertiary,
LogLevel.error => colorScheme.error,
RecordTone.warning => colorScheme.tertiary,
RecordTone.error => colorScheme.error,
RecordTone.muted || RecordTone.neutral => null,
};
}
Color? tintColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.warning => colorScheme.tertiaryContainer.withValues(
alpha: 0.2,
),
RecordTone.error => colorScheme.errorContainer.withValues(alpha: 0.2),
RecordTone.muted || RecordTone.neutral => null,
};
}
Color labelColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.muted => colorScheme.outline,
RecordTone.neutral => colorScheme.onSurfaceVariant,
RecordTone.warning => colorScheme.onTertiaryContainer,
RecordTone.error => colorScheme.onErrorContainer,
};
}
Color labelContainerColor(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (this) {
RecordTone.muted => colorScheme.surfaceContainerHigh,
RecordTone.neutral => colorScheme.surfaceContainerHighest,
RecordTone.warning => colorScheme.tertiaryContainer,
RecordTone.error => colorScheme.errorContainer,
};
}
}
@@ -344,7 +377,7 @@ enum HotAction {
exit,
}
enum ProxiesIconStyle { none, standard, icon }
enum ProxiesIconStyle { filled, plain, hidden }
enum FontFamily {
twEmoji('Twemoji'),
@@ -662,6 +695,7 @@ enum LoadingTag {
access,
proxies,
batteryOptimization,
checkUpdate,
}
enum CoreStatus { connecting, connected, disconnected }
+1
View File
@@ -1,2 +1,3 @@
export 'tracker_info_item.dart';
export 'tracker_info_list.dart';
export 'tracker_speed_ranker.dart';
+180 -158
View File
@@ -1,64 +1,47 @@
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/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
String _ruleText(TrackerInfo trackerInfo) {
final rule = trackerInfo.rule;
final rulePayload = trackerInfo.rulePayload;
if (rulePayload.isNotEmpty) {
return '$rule($rulePayload)';
}
return rule;
}
String _endpointText(String ip, String port) {
if (ip.isEmpty) {
return '';
}
if (port.isNotEmpty) {
return '$ip:$port';
}
return ip;
}
class TrackerInfoItem extends ConsumerWidget {
final TrackerInfo trackerInfo;
final bool isLive;
final Function(String)? onClickKeyword;
final Widget? trailing;
final Widget? action;
final String detailTitle;
const TrackerInfoItem({
super.key,
required this.trackerInfo,
this.isLive = false,
this.onClickKeyword,
this.trailing,
this.action,
required this.detailTitle,
});
Widget _buildMeta(BuildContext context) {
final traffic = Traffic(up: trackerInfo.upload, down: trackerInfo.download);
final chains = trackerInfo.chains;
final metaText =
'${trackerInfo.start.getLastUpdateTimeDesc(context)} · ${traffic.desc}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
),
if (chains.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 0),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (final chain in chains)
CommonChip(
label: chain,
onPressed: () => onClickKeyword?.call(chain),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context, ref) {
final showIcon = ref.watch(
@@ -67,48 +50,155 @@ class TrackerInfoItem extends ConsumerWidget {
state.findProcessMode == FindProcessMode.always && system.isAndroid,
),
);
final process = trackerInfo.metadata.process;
final icon = showIcon
? GestureDetector(
onTap: () {
if (process.isEmpty) return;
onClickKeyword?.call(process);
},
child: Padding(
padding: const EdgeInsetsGeometry.only(top: 6),
child: PackageIcon(packageName: process, size: 44),
),
)
: null;
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
).copyWith(bottom: 12),
minVerticalPadding: 0,
horizontalTitleGap: 12,
tileTitleAlignment: ListTileTitleAlignment.top,
return RecordListItem(
onTap: () {
showExtend(
context,
builder: (_) {
return AdaptiveSheetScaffold(
sheetTransparentToolBar: true,
return CommonScaffold(
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
title: detailTitle,
);
},
);
},
leading: icon,
title: Text(
trackerInfo.desc,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge,
header: _buildHeader(context),
body: _buildBody(showIcon: showIcon),
);
}
Widget _buildBody({required bool showIcon}) {
final process = trackerInfo.metadata.process;
final body = _TrackerInfoBody(
trackerInfo: trackerInfo,
onClickKeyword: onClickKeyword,
);
if (!showIcon || process.isEmpty) {
return body;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
GestureDetector(
onTap: () => onClickKeyword?.call(process),
child: PackageIcon(packageName: process, size: 40),
),
Expanded(child: body),
],
);
}
Widget _buildHeader(BuildContext context) {
final network = Text(
trackerInfo.metadata.network.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.w500),
);
if (!isLive) {
return RecordHeader(
trailing: action,
children: [RecordTimestamp(trackerInfo.start.showFull), network],
);
}
final color = context.colorScheme.onSurfaceVariant;
WidgetSpan arrow(Glyph glyph) => WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GlyphIcon(glyph, size: 12, color: color),
);
return RecordHeader(
trailing: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Text.rich(
TextSpan(
children: [
arrow(AppGlyphs.arrowUp),
TextSpan(
text: ' ${(trackerInfo.uploadSpeed ?? 0).traffic.show}/s ',
),
arrow(AppGlyphs.arrowDown),
TextSpan(
text: ' ${(trackerInfo.downloadSpeed ?? 0).traffic.show}/s',
),
],
),
),
?action,
],
),
subtitle: _buildMeta(context),
trailing: trailing,
children: [
Text(trackerInfo.start.getLastUpdateTimeDesc(context)),
network,
],
);
}
}
class _TrackerInfoBody extends StatelessWidget {
final TrackerInfo trackerInfo;
final Function(String)? onClickKeyword;
const _TrackerInfoBody({required this.trackerInfo, this.onClickKeyword});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final styles = RecordTextStyles.of(context);
final metadata = trackerInfo.metadata;
final rule = _ruleText(trackerInfo);
final source = [
trackerInfo.progressText,
_endpointText(metadata.sourceIP, metadata.sourcePort),
].where((text) => text.isNotEmpty).join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: _endpointText(
trackerInfo.title,
metadata.destinationPort,
),
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
),
if (metadata.host.isNotEmpty && metadata.destinationIP.isNotEmpty)
TextSpan(
text: ' ${metadata.destinationIP}',
style: styles.muted,
),
],
),
),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (rule.isNotEmpty) Text(rule, style: styles.secondary),
for (final (index, chain) in trackerInfo.chains.reversed.indexed)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
if (index > 0 || rule.isNotEmpty) const RecordArrow(),
Flexible(
child: TonalChip(
label: chain,
color: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
onPressed: () => onClickKeyword?.call(chain),
),
),
],
),
],
),
if (source.isNotEmpty) Text(source, style: styles.muted),
],
);
}
}
@@ -118,52 +208,24 @@ class TrackerInfoDetailView extends StatelessWidget {
const TrackerInfoDetailView({super.key, required this.trackerInfo});
String _getRuleText() {
final rule = trackerInfo.rule;
final rulePayload = trackerInfo.rulePayload;
if (rulePayload.isNotEmpty) {
return '$rule($rulePayload)';
}
return rule;
}
String _getProcessText() {
final process = trackerInfo.metadata.process;
final uid = trackerInfo.metadata.uid;
if (uid != 0) {
return '$process($uid)';
}
return process;
}
String _getEndpointText(String ip, String port) {
if (ip.isEmpty) {
return '';
}
if (port.isNotEmpty) {
return '$ip:$port';
}
return ip;
}
Widget _buildChains(BuildContext context) {
return DecorationListItem(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 20,
return DetailRow(
title: context.appLocalizations.proxyChains,
value: Wrap(
spacing: 6,
runSpacing: 4,
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(context.appLocalizations.proxyChains),
Flexible(
child: Wrap(
for (final (index, chain) in trackerInfo.chains.reversed.indexed)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
runSpacing: 4,
alignment: WrapAlignment.end,
children: [
for (final chain in trackerInfo.chains) MetaChip(label: chain),
if (index > 0) const RecordArrow(),
Flexible(child: MetaChip(label: chain)),
],
),
),
],
),
);
@@ -172,7 +234,7 @@ class TrackerInfoDetailView extends StatelessWidget {
List<Widget> _buildRows(List<(String, String)> entries) {
return [
for (final (title, value) in entries)
if (value.isNotEmpty) _DetailRow(title: title, value: value),
if (value.isNotEmpty) DetailRow.text(title: title, value: value),
];
}
@@ -183,15 +245,15 @@ class TrackerInfoDetailView extends StatelessWidget {
return ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.sheetTopPadding),
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
title: appLocalizations.basicInfo,
items: _buildRows([
(appLocalizations.creationTime, trackerInfo.start.showFull),
(appLocalizations.networkType, metadata.network),
(appLocalizations.process, _getProcessText()),
(appLocalizations.rule, _getRuleText()),
(appLocalizations.process, trackerInfo.progressText),
(appLocalizations.rule, _ruleText(trackerInfo)),
(appLocalizations.upload, trackerInfo.upload.traffic.show),
(appLocalizations.download, trackerInfo.download.traffic.show),
]),
@@ -202,14 +264,11 @@ class TrackerInfoDetailView extends StatelessWidget {
(appLocalizations.host, metadata.host),
(
appLocalizations.source,
_getEndpointText(metadata.sourceIP, metadata.sourcePort),
_endpointText(metadata.sourceIP, metadata.sourcePort),
),
(
appLocalizations.destination,
_getEndpointText(
metadata.destinationIP,
metadata.destinationPort,
),
_endpointText(metadata.destinationIP, metadata.destinationPort),
),
(
appLocalizations.destinationGeoIP,
@@ -234,40 +293,3 @@ class TrackerInfoDetailView extends StatelessWidget {
);
}
}
class _DetailRow extends StatelessWidget {
final String title;
final String value;
const _DetailRow({required this.title, required this.value});
Future<void> _copy(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: value));
if (!context.mounted) return;
context.showNotifier(context.appLocalizations.copySuccess);
}
@override
Widget build(BuildContext context) {
return DecorationListItem(
onPressed: () => _copy(context),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 20,
children: [
Text(title),
Flexible(
child: Text(
value,
textAlign: TextAlign.end,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}
+12 -8
View File
@@ -49,7 +49,7 @@ class TrackerInfoList extends StatelessWidget {
final bool shrinkWrap;
final ScrollPhysics? physics;
final EdgeInsetsGeometry? padding;
final Widget? Function(TrackerInfo trackerInfo)? trailingBuilder;
final Widget? Function(TrackerInfo trackerInfo)? actionBuilder;
const TrackerInfoList({
super.key,
@@ -60,7 +60,7 @@ class TrackerInfoList extends StatelessWidget {
this.shrinkWrap = false,
this.physics,
this.padding,
this.trailingBuilder,
this.actionBuilder,
});
@override
@@ -76,8 +76,9 @@ class TrackerInfoList extends StatelessWidget {
itemBuilder: (_, index) => _buildTrackerInfoItem(
context,
trackerInfos[index],
isLive: false,
detailTitle: detailTitle,
trailingBuilder: trailingBuilder,
actionBuilder: actionBuilder,
),
);
}
@@ -90,7 +91,7 @@ class TrackerInfoAnimatedList extends StatelessWidget {
final String detailTitle;
final ScrollController? controller;
final EdgeInsetsGeometry? padding;
final Widget? Function(TrackerInfo trackerInfo)? trailingBuilder;
final Widget? Function(TrackerInfo trackerInfo)? actionBuilder;
const TrackerInfoAnimatedList({
super.key,
@@ -98,7 +99,7 @@ class TrackerInfoAnimatedList extends StatelessWidget {
required this.detailTitle,
this.controller,
this.padding,
this.trailingBuilder,
this.actionBuilder,
});
@override
@@ -112,8 +113,9 @@ class TrackerInfoAnimatedList extends StatelessWidget {
itemBuilder: (context, trackerInfo) => _buildTrackerInfoItem(
context,
trackerInfo,
isLive: true,
detailTitle: detailTitle,
trailingBuilder: trailingBuilder,
actionBuilder: actionBuilder,
),
);
}
@@ -122,16 +124,18 @@ class TrackerInfoAnimatedList extends StatelessWidget {
Widget _buildTrackerInfoItem(
BuildContext context,
TrackerInfo trackerInfo, {
required bool isLive,
required String detailTitle,
required Widget? Function(TrackerInfo trackerInfo)? trailingBuilder,
required Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
}) {
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
isLive: isLive,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
trailing: trailingBuilder?.call(trackerInfo),
action: actionBuilder?.call(trackerInfo),
detailTitle: detailTitle,
);
}
@@ -0,0 +1,115 @@
import 'dart:math';
import 'package:fl_clash/models/models.dart';
class _SpeedSample {
final int upload;
final int download;
final DateTime at;
final int uploadSpeed;
final int downloadSpeed;
final double rankSpeed;
const _SpeedSample({
required this.upload,
required this.download,
required this.at,
required this.uploadSpeed,
required this.downloadSpeed,
required this.rankSpeed,
});
}
/// Derives per-connection speed from successive cumulative snapshots and
/// orders connections by it without reshuffling the list every poll.
class TrackerSpeedRanker {
// Closing a connection triggers an out-of-band refresh; a delta over a few
// milliseconds would read as a spike, so such samples keep the last speed.
static const _minSampleGap = Duration(milliseconds: 500);
static const _smoothing = 0.5;
final _samples = <String, _SpeedSample>{};
var _positions = const <String, int>{};
List<TrackerInfo> rank(List<TrackerInfo> trackerInfos, DateTime now) {
final samples = <String, _SpeedSample>{
for (final trackerInfo in trackerInfos)
trackerInfo.id: _sample(trackerInfo, now),
};
_samples
..clear()
..addAll(samples);
int bucketOf(TrackerInfo trackerInfo) {
final speed = samples[trackerInfo.id]!.rankSpeed;
return speed < 1 ? 0 : (log(speed) / ln2).floor() + 1;
}
final ranked =
[
for (final trackerInfo in trackerInfos)
trackerInfo.copyWith(
uploadSpeed: samples[trackerInfo.id]!.uploadSpeed,
downloadSpeed: samples[trackerInfo.id]!.downloadSpeed,
),
]..sort((a, b) {
final bucketA = bucketOf(a);
final bucket = bucketOf(b).compareTo(bucketA);
if (bucket != 0) {
return bucket;
}
if (bucketA > 0) {
final position = (_positions[a.id] ?? trackerInfos.length)
.compareTo(_positions[b.id] ?? trackerInfos.length);
if (position != 0) {
return position;
}
}
final traffic = (b.upload + b.download).compareTo(
a.upload + a.download,
);
if (traffic != 0) {
return traffic;
}
final start = b.start.compareTo(a.start);
return start != 0 ? start : a.id.compareTo(b.id);
});
_positions = {
for (final (index, trackerInfo) in ranked.indexed) trackerInfo.id: index,
};
return ranked;
}
_SpeedSample _sample(TrackerInfo trackerInfo, DateTime now) {
final previous = _samples[trackerInfo.id];
if (previous == null) {
return _SpeedSample(
upload: trackerInfo.upload,
download: trackerInfo.download,
at: now,
uploadSpeed: 0,
downloadSpeed: 0,
rankSpeed: 0,
);
}
final elapsed = now.difference(previous.at);
if (elapsed < _minSampleGap) {
return previous;
}
final seconds = elapsed.inMicroseconds / Duration.microsecondsPerSecond;
final uploadSpeed = max(0, trackerInfo.upload - previous.upload) / seconds;
final downloadSpeed =
max(0, trackerInfo.download - previous.download) / seconds;
final rankSpeed =
previous.rankSpeed +
(uploadSpeed + downloadSpeed - previous.rankSpeed) * _smoothing;
return _SpeedSample(
upload: trackerInfo.upload,
download: trackerInfo.download,
at: now,
uploadSpeed: uploadSpeed.round(),
downloadSpeed: downloadSpeed.round(),
rankSpeed: rankSpeed < 1 ? 0 : rankSpeed,
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1879 -576
View File
File diff suppressed because it is too large Load Diff
+300 -68
View File
@@ -5,6 +5,7 @@ import 'dart:math';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/glyph.dart';
import 'package:material_ui/material_ui.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -17,7 +18,7 @@ part 'generated/common.g.dart';
@freezed
abstract class NavigationItem with _$NavigationItem {
const factory NavigationItem({
required Icon icon,
required Glyph glyph,
required PageLabel label,
required WidgetBuilder builder,
@Default(true) bool keep,
@@ -42,9 +43,7 @@ abstract class Package with _$Package {
}
extension PackagesExt on List<Package> {
List<Package> getViewList({
required List<String> pinedList,
required AccessSortType sortType,
Iterable<Package> whereVisible({
required bool isFilterSystemApp,
required bool isFilterNonInternetApp,
}) {
@@ -52,9 +51,22 @@ extension PackagesExt on List<Package> {
(item) =>
(isFilterSystemApp ? item.system == false : true) &&
(isFilterNonInternetApp ? item.internet == true : true),
);
}
List<Package> getViewList({
required List<String> pinedList,
required AccessSortType sortType,
required bool isFilterSystemApp,
required bool isFilterNonInternetApp,
}) {
final pinned = pinedList.toSet();
return whereVisible(
isFilterSystemApp: isFilterSystemApp,
isFilterNonInternetApp: isFilterNonInternetApp,
).sorted((a, b) {
final isSelectA = pinedList.contains(a.packageName);
final isSelectB = pinedList.contains(b.packageName);
final isSelectA = pinned.contains(a.packageName);
final isSelectB = pinned.contains(b.packageName);
if (isSelectA != isSelectB) {
return isSelectA ? -1 : 1;
@@ -122,17 +134,6 @@ extension TrackerInfoExt on TrackerInfo {
return metadata.destinationIP;
}
String get desc {
var text = '${metadata.network}://';
final ips = [
metadata.host,
metadata.destinationIP,
].where((ip) => ip.isNotEmpty);
text += ips.join('/');
text += ':${metadata.destinationPort}';
return text;
}
String get progressText {
final process = metadata.process;
final uid = metadata.uid;
@@ -141,6 +142,25 @@ extension TrackerInfoExt on TrackerInfo {
}
return process.trim();
}
List<String> get searchFields => [
metadata.network,
metadata.host,
metadata.destinationIP,
metadata.destinationPort,
metadata.sourceIP,
metadata.sourcePort,
metadata.process,
metadata.processPath,
metadata.remoteDestination,
metadata.destinationIPASN,
...metadata.destinationGeoIP,
metadata.specialProxy,
metadata.specialRules,
rule,
rulePayload,
...chains,
];
}
String _logDateTime(dynamic _) {
@@ -172,15 +192,26 @@ abstract class LogsState with _$LogsState {
}) = _LogsState;
}
final _logSearchTexts = Expando<String>();
extension LogsStateExt on LogsState {
bool get isSearching => keywords.isNotEmpty || SearchQuery(query).isNotEmpty;
List<Log> get list {
final lowQuery = query.toLowerCase();
return logs.where((log) {
final logLevelName = log.logLevel.name;
return {logLevelName}.containsAll(keywords) &&
((log.payload.toLowerCase().contains(lowQuery)) ||
logLevelName.contains(lowQuery));
}).toList();
final searchQuery = SearchQuery(query);
if (keywords.isEmpty && searchQuery.isEmpty) {
return logs;
}
return logs
.where(
(log) => keywords.every((keyword) => keyword == log.logLevel.name),
)
.whereMatches(
searchQuery,
(log) => [log.payload, log.logLevel.name],
texts: _logSearchTexts,
)
.toList();
}
}
@@ -194,26 +225,101 @@ abstract class TrackerInfosState with _$TrackerInfosState {
}) = _TrackerInfosState;
}
final _trackerInfoSearchTexts = Expando<String>();
extension TrackerInfosStateExt on TrackerInfosState {
bool get isSearching => keywords.isNotEmpty || SearchQuery(query).isNotEmpty;
List<TrackerInfo> get list {
final lowerQuery = query.toLowerCase().trim();
final lowQuery = query.toLowerCase();
return trackerInfos.where((trackerInfo) {
final chains = trackerInfo.chains;
final process = trackerInfo.metadata.process;
final networkText = trackerInfo.metadata.network.toLowerCase();
final hostText = trackerInfo.metadata.host.toLowerCase();
final destinationIPText = trackerInfo.metadata.destinationIP
.toLowerCase();
final processText = trackerInfo.metadata.process.toLowerCase();
final chainsText = chains.join('').toLowerCase();
return {...chains, process}.containsAll(keywords) &&
(networkText.contains(lowerQuery) ||
hostText.contains(lowerQuery) ||
destinationIPText.contains(lowQuery) ||
processText.contains(lowerQuery) ||
chainsText.contains(lowerQuery));
}).toList();
final searchQuery = SearchQuery(query);
if (keywords.isEmpty && searchQuery.isEmpty) {
return trackerInfos;
}
return trackerInfos
.where(
(trackerInfo) => keywords.every(
(keyword) =>
keyword == trackerInfo.metadata.process ||
trackerInfo.chains.contains(keyword),
),
)
.whereMatches(
searchQuery,
(trackerInfo) => trackerInfo.searchFields,
texts: _trackerInfoSearchTexts,
)
.toList();
}
}
@freezed
abstract class DnsQuery with _$DnsQuery {
const factory DnsQuery({
required String domain,
required String type,
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
DnsQueryInitiator? initiator,
@Default('') String upstream,
@Default(false) bool cached,
@Default([]) List<String> answers,
@Default('') String rcode,
@Default('') String error,
@Default(0) int delay,
required DateTime time,
}) = _DnsQuery;
factory DnsQuery.fromJson(Map<String, Object?> json) =>
_$DnsQueryFromJson(json);
}
extension DnsQueryExt on DnsQuery {
bool get hasFailureRcode => rcode.isNotEmpty && rcode != 'NOERROR';
bool get isFailed => error.isNotEmpty || hasFailureRcode;
List<String> get tags => [type, ...resultTags];
List<String> get resultTags {
final initiator = this.initiator;
return [
if (initiator != null) initiator.label,
if (cached) currentAppLocalizations.cache,
if (hasFailureRcode) rcode,
];
}
List<String> get searchFields => [
domain,
...tags,
rcode,
upstream,
error,
...answers,
];
}
@freezed
abstract class DnsQueriesState with _$DnsQueriesState {
const factory DnsQueriesState({
@Default([]) List<DnsQuery> dnsQueries,
@Default([]) List<String> keywords,
@Default('') String query,
@Default(true) bool autoScrollToEnd,
}) = _DnsQueriesState;
}
extension DnsQueriesStateExt on DnsQueriesState {
bool get isSearching => keywords.isNotEmpty || SearchQuery(query).isNotEmpty;
List<DnsQuery> get list {
final searchQuery = SearchQuery(query);
if (keywords.isEmpty && searchQuery.isEmpty) {
return dnsQueries;
}
return dnsQueries
.where((dnsQuery) => keywords.every(dnsQuery.tags.contains))
.whereMatches(searchQuery, (dnsQuery) => dnsQuery.searchFields)
.toList();
}
}
@@ -343,15 +449,6 @@ extension FileInfoFileExt on File {
}
}
extension FileInfoExt on FileInfo {
String getDesc(BuildContext context) {
final lastModifiedDesc =
lastModified?.getLastUpdateTimeDesc(context) ??
context.appLocalizations.unknown;
return '${size.traffic.show} · $lastModifiedDesc';
}
}
@freezed
abstract class VersionInfo with _$VersionInfo {
const factory VersionInfo({
@@ -376,10 +473,6 @@ extension TrafficExt on Traffic {
return '↑ ${up.traffic.show}/s ↓ ${down.traffic.show}/s';
}
String get desc {
return '${up.traffic.show} ↑ ${down.traffic.show} ↓';
}
String get trayTitle {
return '${up.shortTraffic.show}/s\n${down.shortTraffic.show}/s';
}
@@ -483,7 +576,7 @@ abstract class IpInfo with _$IpInfo {
};
}
static IpInfo fromIpApiCoJson(Map<String, dynamic> json) {
static IpInfo fromGeoJsJson(Map<String, dynamic> json) {
return switch (json) {
{'ip': final String ip, 'country_code': final String countryCode} =>
IpInfo(ip: ip, countryCode: countryCode),
@@ -507,9 +600,9 @@ abstract class IpInfo with _$IpInfo {
};
}
static IpInfo fromMyIpJson(Map<String, dynamic> json) {
static IpInfo fromCountryIsJson(Map<String, dynamic> json) {
return switch (json) {
{'ip': final String ip, 'cc': final String countryCode} => IpInfo(
{'ip': final String ip, 'country': final String countryCode} => IpInfo(
ip: ip,
countryCode: countryCode,
),
@@ -534,6 +627,34 @@ abstract class IpInfo with _$IpInfo {
_ => throw const FormatException('invalid json'),
};
}
static IpInfo fromIpQueryJson(Map<String, dynamic> json) {
return switch (json) {
{
'ip': final String ip,
'location': {'country_code': final String countryCode},
} =>
IpInfo(ip: ip, countryCode: countryCode),
_ => throw const FormatException('invalid json'),
};
}
static IpInfo fromCloudflareTrace(String body) {
final fields = <String, String>{};
for (final line in const LineSplitter().convert(body)) {
final separator = line.indexOf('=');
if (separator > 0) {
fields[line.substring(0, separator)] = line.substring(separator + 1);
}
}
return switch (fields) {
{'ip': final ip, 'loc': final countryCode} => IpInfo(
ip: ip,
countryCode: countryCode,
),
_ => throw const FormatException('invalid trace'),
};
}
}
@freezed
@@ -594,15 +715,18 @@ abstract class Script with _$Script {
required int id,
required String label,
required DateTime lastUpdateTime,
String? url,
int? order,
}) = _Script;
factory Script.fromJson(Map<String, Object?> json) => _$ScriptFromJson(json);
factory Script.create({required String label}) {
factory Script.create({required String label, String? url}) {
return Script(
id: snowflake.id,
label: label,
lastUpdateTime: DateTime.now(),
url: url,
);
}
}
@@ -618,20 +742,28 @@ extension ScriptsExt on List<Script> {
}
return null;
}
bool hasLabel(String label, {Script? except}) {
return any((script) => script.id != except?.id && script.label == label);
}
String uniqueLabel(String name, {required String fallback}) {
return uniqueLabelFor(
name,
fallback: fallback,
taken: (label) => hasLabel(label),
);
}
}
extension ScriptExt on Script {
String get fileName => '$id.js';
String get updatingKey => 'script_$id';
Future<String> get path async => appPath.getScriptPath(id.toString());
Future<String?> get content async {
final file = File(await path);
if (await file.exists()) {
return file.readAsString();
}
return null;
}
Future<String?> get content async => readTextFileTask(await path);
Future<Script> save(String content) async {
final file = File(await path);
@@ -642,6 +774,11 @@ extension ScriptExt on Script {
return copyWith(lastUpdateTime: DateTime.now());
}
Future<Script> update() async {
final response = await request.getTextResponseForUrl(url!);
return save(response.data ?? '');
}
Future<Script> saveWithPath(String copyPath) async {
final file = File(await path);
if (!await file.exists()) {
@@ -652,6 +789,100 @@ extension ScriptExt on Script {
}
}
@freezed
abstract class ClashProvider with _$ClashProvider {
const factory ClashProvider({
required int id,
required ProviderKind kind,
required String label,
@Default('') String url,
RuleProviderBehavior? behavior,
RuleProviderFormat? format,
int? order,
}) = _ClashProvider;
factory ClashProvider.create({
required ProviderKind kind,
required String label,
String url = '',
}) {
return ClashProvider(
id: snowflake.id,
kind: kind,
label: label,
url: url,
behavior: kind == ProviderKind.rule
? RuleProviderBehavior.classical
: null,
format: kind == ProviderKind.rule ? RuleProviderFormat.yaml : null,
);
}
}
RuleProviderFormat? ruleProviderFormatOf(String sourceName) {
final dot = sourceName.lastIndexOf('.');
if (dot <= 0) {
return null;
}
return switch (sourceName.substring(dot + 1).toLowerCase()) {
'mrs' => RuleProviderFormat.mrs,
'txt' || 'list' || 'conf' => RuleProviderFormat.text,
'yaml' || 'yml' => RuleProviderFormat.yaml,
_ => null,
};
}
extension ClashProviderExt on ClashProvider {
/// Keyed by url so an edited url downloads afresh instead of reusing the
/// cached body the old one left behind.
String get fileName => '$id@$url'.toMd5();
bool get isRemote => url.isNotEmpty;
/// An mrs set is a zstd stream, which no text editor can round-trip.
bool get isTextContent => format != RuleProviderFormat.mrs;
Future<String> get path => appPath.getProviderCachePath(kind, fileName);
Future<String?> get content async => readTextFileTask(await path);
Future<void> saveContent(List<int> bytes) async {
await File(await path).safeWriteAsBytes(bytes);
}
ClashProvider withFileFormat(String sourceName) {
return withFormat(
ruleProviderFormatOf(sourceName) ?? RuleProviderFormat.yaml,
);
}
/// The core takes mrs only under domain or ipcidr.
ClashProvider withFormat(RuleProviderFormat nextFormat) {
if (kind != ProviderKind.rule) {
return this;
}
final behaviors = nextFormat.behaviors;
return copyWith(
format: nextFormat,
behavior: behaviors.contains(behavior) ? behavior : behaviors.first,
);
}
Map<String, dynamic> definition(String path) {
final vehicle = isRemote
? {'type': 'http', 'url': url, 'path': path}
: {'type': 'file', 'path': path};
return switch (kind) {
ProviderKind.proxy => {...vehicle},
ProviderKind.rule => {
...vehicle,
'behavior': (behavior ?? RuleProviderBehavior.classical).name,
'format': (format ?? RuleProviderFormat.yaml).name,
},
};
}
}
@freezed
abstract class DelayState with _$DelayState {
const factory DelayState({required int delay, required bool group}) =
@@ -689,8 +920,9 @@ abstract class UpdatingMessage with _$UpdatingMessage {
@freezed
abstract class IconButtonData with _$IconButtonData {
const factory IconButtonData({
required IconData icon,
required VoidCallback onPressed,
required Glyph glyph,
required VoidCallback? onPressed,
String? tooltip,
@Default(false) bool isLoading,
}) = _IconButtonData;
}
File diff suppressed because it is too large Load Diff
+51
View File
@@ -125,6 +125,46 @@ const _$LogLevelEnumMap = {
LogLevel.silent: 'silent',
};
_DnsQuery _$DnsQueryFromJson(Map<String, dynamic> json) => _DnsQuery(
domain: json['domain'] as String,
type: json['type'] as String,
initiator: $enumDecodeNullable(
_$DnsQueryInitiatorEnumMap,
json['initiator'],
unknownValue: JsonKey.nullForUndefinedEnumValue,
),
upstream: json['upstream'] as String? ?? '',
cached: json['cached'] as bool? ?? false,
answers:
(json['answers'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
rcode: json['rcode'] as String? ?? '',
error: json['error'] as String? ?? '',
delay: (json['delay'] as num?)?.toInt() ?? 0,
time: DateTime.parse(json['time'] as String),
);
Map<String, dynamic> _$DnsQueryToJson(_DnsQuery instance) => <String, dynamic>{
'domain': instance.domain,
'type': instance.type,
'initiator': _$DnsQueryInitiatorEnumMap[instance.initiator],
'upstream': instance.upstream,
'cached': instance.cached,
'answers': instance.answers,
'rcode': instance.rcode,
'error': instance.error,
'delay': instance.delay,
'time': instance.time.toIso8601String(),
};
const _$DnsQueryInitiatorEnumMap = {
DnsQueryInitiator.app: 'app',
DnsQueryInitiator.rule: 'rule',
DnsQueryInitiator.direct: 'direct',
DnsQueryInitiator.proxy: 'proxy',
DnsQueryInitiator.other: 'other',
};
_DAVProps _$DAVPropsFromJson(Map<String, dynamic> json) => _DAVProps(
uri: json['uri'] as String,
user: json['user'] as String,
@@ -218,6 +258,13 @@ const _$HotActionEnumMap = {
HotAction.mode: 'mode',
HotAction.proxy: 'proxy',
HotAction.tun: 'tun',
HotAction.ruleMode: 'ruleMode',
HotAction.globalMode: 'globalMode',
HotAction.directMode: 'directMode',
HotAction.delayTest: 'delayTest',
HotAction.updateProfiles: 'updateProfiles',
HotAction.copyEnv: 'copyEnv',
HotAction.exit: 'exit',
};
const _$KeyboardModifierEnumMap = {
@@ -233,10 +280,14 @@ _Script _$ScriptFromJson(Map<String, dynamic> json) => _Script(
id: (json['id'] as num).toInt(),
label: json['label'] as String,
lastUpdateTime: DateTime.parse(json['lastUpdateTime'] as String),
url: json['url'] as String?,
order: (json['order'] as num?)?.toInt(),
);
Map<String, dynamic> _$ScriptToJson(_Script instance) => <String, dynamic>{
'id': instance.id,
'label': instance.label,
'lastUpdateTime': instance.lastUpdateTime.toIso8601String(),
'url': instance.url,
'order': instance.order,
};
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -53,7 +53,6 @@ abstract class MessageActionState with _$MessageActionState {
@freezed
abstract class AppBarState with _$AppBarState {
const factory AppBarState({
@Default([]) List<Widget> actions,
AppBarSearchState? searchState,
AppBarEditState? editState,
}) = _AppBarState;
@@ -63,7 +62,6 @@ abstract class AppBarState with _$AppBarState {
abstract class AppBarSearchState with _$AppBarSearchState {
const factory AppBarSearchState({
required Function(String) onSearch,
@Default(true) bool autoAddSearch,
@Default(null) String? query,
}) = _AppBarSearchState;
}
@@ -92,14 +90,6 @@ abstract class ProfilesState with _$ProfilesState {
}) = _ProfilesState;
}
@freezed
abstract class NetworkDetectionState with _$NetworkDetectionState {
const factory NetworkDetectionState({
required bool isLoading,
required IpInfo? ipInfo,
}) = _NetworkDetectionState;
}
@freezed
abstract class TrayState with _$TrayState {
const factory TrayState({
@@ -112,6 +102,8 @@ abstract class TrayState with _$TrayState {
required List<Group> groups,
required Map<String, String> selectedMap,
required bool showTrayTitle,
required bool safeMode,
@Default({}) Map<HotAction, HotKeyAction> hotKeys,
}) = _TrayState;
}
@@ -349,13 +341,18 @@ abstract class MakeRealProfileState with _$MakeRealProfileState {
required Map<String, dynamic> rawConfig,
required PatchClashConfig realPatchConfig,
required bool overrideDns,
required bool overrideNtp,
required bool appendSystemDns,
required List<ProxyGroup> proxyGroups,
required List<Rule> rules,
required List<Rule> addedRules,
required String defaultUA,
@Default([]) List<CustomProxy> proxies,
@Default([]) List<String> authentication,
@Default({}) Map<String, dynamic> injectedProxyProviders,
@Default({}) Map<String, dynamic> injectedRuleProviders,
String? matchTarget,
@Default(false) bool safeMode,
}) = _MakeRealProfileState;
}
@@ -368,6 +365,8 @@ abstract class MigrationData with _$MigrationData {
@Default([]) List<Profile> profiles,
@Default([]) List<ProfileRuleLink> links,
@Default([]) List<ProxyGroup> proxyGroups,
@Default([]) List<ClashProvider> clashProviders,
@Default([]) List<CustomProxy> customProxies,
}) = _MigrationData;
}
@@ -381,8 +380,12 @@ abstract class SetupState with _$SetupState {
required List<ProxyGroup> proxyGroups,
required List<Rule> addedRules,
required Script? script,
@Default([]) List<CustomProxy> customProxies,
required bool overrideDns,
required Dns dns,
required Set<DnsOverrideKey> dnsOverrideKeys,
@Default([]) List<ClashProvider> clashProviders,
@Default({}) Map<String, int> profileProviders,
String? matchTarget,
}) = _SetupState;
}
+695 -131
View File
@@ -1,15 +1,33 @@
import 'dart:async';
import 'dart:math';
import 'package:fl_clash/common/color.dart';
import 'package:fl_clash/common/context.dart';
import 'package:fl_clash/common/shape.dart';
import 'package:fl_clash/providers/action.dart';
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/plugins/app.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/activate_box.dart';
import 'package:fl_clash/widgets/navigation_dock.dart';
import 'package:fl_clash/widgets/null_status.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
const _systemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
);
const _invalidHintDuration = Duration(seconds: 2);
class ScanPage extends ConsumerStatefulWidget {
const ScanPage({super.key});
@@ -18,38 +36,132 @@ class ScanPage extends ConsumerStatefulWidget {
}
class _ScanPageState extends ConsumerState<ScanPage>
with WidgetsBindingObserver {
MobileScannerController controller = MobileScannerController(
with WidgetsBindingObserver, SingleTickerProviderStateMixin {
final _controller = MobileScannerController(
autoStart: false,
detectionSpeed: DetectionSpeed.noDuplicates,
formats: const [BarcodeFormat.qrCode],
);
StreamSubscription<Object?>? _subscription;
late final AnimationController _effectController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 4800),
);
final _invalidHint = ValueNotifier<bool>(false);
StreamSubscription<BarcodeCapture>? _subscription;
Timer? _invalidHintTimer;
bool _disableAnimations = false;
bool _completed = false;
bool _picking = false;
bool _restartAfterSettings = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_listenBarcodes();
unawaited(controller.start());
_controller.addListener(_syncEffect);
_subscription = _controller.barcodes.listen(
_handleCapture,
onError: _handleDetectError,
);
unawaited(_start());
}
void _handleBarcode(BarcodeCapture barcodeCapture) {
if (!mounted) {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_disableAnimations = context.disableAnimations;
_syncEffect();
}
void _syncEffect() {
if (_disableAnimations || _controller.value.error != null) {
_effectController.stop();
} else if (!_effectController.isAnimating) {
_effectController.repeat();
}
}
// The permission dialog makes Android report `inactive` then `resumed` while
// the first start is still pending, and a second `start` would throw.
Future<void> _start() async {
final state = _controller.value;
if (_completed || state.isStarting || state.isRunning) {
return;
}
final barcode = barcodeCapture.barcodes.first;
if (barcode.type == BarcodeType.url) {
Navigator.pop<String>(context, barcode.rawValue);
} else {
Navigator.pop(context);
try {
await _controller.start();
} on MobileScannerException catch (error) {
if (mounted) {
commonPrint.log(
'Failed to start the scanner: ${compactError(error)}',
logLevel: LogLevel.warning,
);
}
}
}
void _listenBarcodes() {
void _handleCapture(BarcodeCapture capture) {
if (_completed || !mounted || capture.barcodes.isEmpty) {
return;
}
final url = profileUrlFromQrCodes(
capture.barcodes.map((barcode) => barcode.rawValue),
);
if (url == null) {
_showInvalidHint();
return;
}
_complete(url);
}
void _handleDetectError(Object error) {
commonPrint.log(
'QR code detection failed: ${compactError(error)}',
logLevel: LogLevel.warning,
);
}
void _showInvalidHint() {
_invalidHintTimer?.cancel();
_invalidHint.value = true;
_invalidHintTimer = Timer(_invalidHintDuration, () {
_invalidHint.value = false;
});
}
// `pop` takes the top route, which is another one once this page is closing
// or a dialog covers it, and a closing page stays mounted until it is gone.
void _complete(String url) {
if (ModalRoute.isCurrentOf(context) == false) {
return;
}
_completed = true;
unawaited(_subscription?.cancel());
_subscription = controller.barcodes.listen(_handleBarcode);
_subscription = null;
Navigator.of(context).pop<String>(url);
}
Future<void> _pickFromAlbum() async {
if (_picking || _completed) {
return;
}
_picking = true;
try {
final url = await globalState.safeRun(picker.pickerConfigQRCode);
if (url != null && mounted && !_completed) {
_complete(url);
}
} finally {
_picking = false;
}
}
void _openAppSettings() {
_restartAfterSettings = true;
unawaited(app?.openAppSettings());
}
@override
@@ -61,130 +173,437 @@ class _ScanPageState extends ConsumerState<ScanPage>
case AppLifecycleState.paused:
return;
case AppLifecycleState.resumed:
_listenBarcodes();
unawaited(controller.start());
// Retrying a denied permission on every resume would reopen the
// system dialog as soon as the user dismisses it.
final denied =
_controller.value.error?.errorCode ==
MobileScannerErrorCode.permissionDenied;
final restart = !denied || _restartAfterSettings;
_restartAfterSettings = false;
if (restart) {
unawaited(_start());
}
case AppLifecycleState.inactive:
unawaited(_subscription?.cancel());
_subscription = null;
unawaited(controller.stop());
unawaited(_controller.stop());
}
}
@override
Widget build(BuildContext context) {
final double sideLength = min(400, MediaQuery.sizeOf(context).width * 0.67);
final scanWindow = Rect.fromCenter(
center: MediaQuery.sizeOf(context).center(Offset.zero),
width: sideLength,
height: sideLength,
);
return Scaffold(
body: Stack(
children: [
Center(
child: MobileScanner(
controller: controller,
scanWindow: scanWindow,
),
),
CustomPaint(painter: ScannerOverlay(scanWindow: scanWindow)),
AppBar(
backgroundColor: Colors.transparent,
automaticallyImplyLeading: false,
leading: IconButton(
tooltip: context.appLocalizations.close,
style: IconButton.styleFrom(
iconSize: 32,
foregroundColor: Colors.white,
),
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.close),
),
actions: [
ValueListenableBuilder<MobileScannerState>(
valueListenable: controller,
builder: (context, state, _) {
var icon = const Icon(Icons.flash_off);
var backgroundColor = Colors.black12;
switch (state.torchState) {
case TorchState.off:
icon = const Icon(Icons.flash_off);
backgroundColor = Colors.black12;
case TorchState.on:
icon = const Icon(Icons.flash_on);
backgroundColor = Colors.orange;
case TorchState.unavailable:
icon = const Icon(Icons.flash_off);
backgroundColor = Colors.transparent;
case TorchState.auto:
icon = const Icon(Icons.flash_auto);
backgroundColor = Colors.orange;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
child: ActivateBox(
active: state.torchState != TorchState.unavailable,
child: IconButton(
tooltip: context.appLocalizations.torch,
color: Colors.white,
icon: icon,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: backgroundColor,
),
onPressed: () => controller.toggleTorch(),
final theme = ThemeData(
useMaterial3: true,
colorScheme: ref.watch(genColorSchemeProvider(Brightness.dark)),
).withAppShapes;
final appLocalizations = context.appLocalizations;
return Theme(
data: theme,
// `Theme` inserts no `IconTheme`, and `IconButton.filled` treats the
// app's light one as a custom color that overrides its defaults.
child: IconTheme(
data: theme.iconTheme,
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle,
sized: false,
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final hintStyle = Theme.of(context).textTheme.bodyMedium!;
final layout = _ScanLayout(
size: constraints.biggest,
padding: MediaQuery.paddingOf(context),
hintHeight:
MediaQuery.textScalerOf(
context,
).scale(hintStyle.fontSize!) *
hintStyle.height! *
_hintMaxLines,
);
return Stack(
children: [
Positioned.fill(
child: MobileScanner(
controller: _controller,
scanWindow: layout.scanWindow,
errorBuilder: (_, _) => const SizedBox.shrink(),
),
),
);
},
),
],
),
Container(
margin: const EdgeInsets.only(bottom: 32),
alignment: Alignment.bottomCenter,
child: IconButton(
tooltip: context.appLocalizations.pickFromAlbum,
color: Colors.white,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.grey,
),
padding: const EdgeInsets.all(16),
iconSize: 32.0,
onPressed: ref
.read(profilesActionProvider.notifier)
.addProfileFormQrCode,
icon: const Icon(Icons.photo_camera_back),
Positioned.fill(
child: ValueListenableBuilder<MobileScannerState>(
valueListenable: _controller,
builder: (context, state, _) {
final error = state.error;
if (error != null) {
return _ScannerError(
errorCode: error.errorCode,
onRetry: _start,
onOpenSettings: _openAppSettings,
);
}
return _buildScanFrame(theme, layout, hintStyle);
},
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
leading: IconButton(
tooltip: appLocalizations.close,
onPressed: () {
Navigator.of(context).pop();
},
icon: const GlyphIcon(AppGlyphs.close),
),
actions: genActions([
IconButton(
tooltip: appLocalizations.pickFromAlbum,
onPressed: _pickFromAlbum,
icon: const GlyphIcon(AppGlyphs.photos),
),
], edge: AppBarActionEdge.icon),
),
),
Positioned.fromRect(
rect: layout.torch,
child: _TorchButton(controller: _controller),
),
],
);
},
),
),
],
),
),
);
}
Widget _buildScanFrame(
ThemeData theme,
_ScanLayout layout,
TextStyle hintStyle,
) {
final colorScheme = theme.colorScheme;
final scanWindow = layout.scanWindow;
final borderRadius = AppCorner.fit(scanWindow.width);
final appLocalizations = context.appLocalizations;
return Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: ScannerOverlay(
scanWindow: scanWindow,
borderRadius: borderRadius,
color: colorScheme.scrim.opacity60,
),
),
),
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
painter: _ScanEffectPainter(
animation: _effectController,
scanWindow: scanWindow,
borderRadius: borderRadius,
color: colorScheme.primary,
highlight: colorScheme.primaryFixed,
contrast: colorScheme.inversePrimary,
),
),
),
),
Positioned.fromRect(
rect: layout.hint,
child: ValueListenableBuilder<bool>(
valueListenable: _invalidHint,
builder: (context, invalid, _) => Semantics(
liveRegion: true,
child: Text(
invalid
? appLocalizations.invalidProfileQrcode
: appLocalizations.qrcodeDesc,
textAlign: TextAlign.center,
maxLines: _hintMaxLines,
overflow: TextOverflow.ellipsis,
style: hintStyle.copyWith(
color: invalid
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
),
),
],
);
}
// `StatefulElement.unmount` asserts that `super.dispose()` already ran by the
// time `dispose()` returns, so nothing here may await first: the controller is
// torn down in the background instead.
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_invalidHintTimer?.cancel();
unawaited(_subscription?.cancel());
_subscription = null;
unawaited(controller.dispose());
_controller.removeListener(_syncEffect);
unawaited(_controller.dispose());
_effectController.dispose();
_invalidHint.dispose();
super.dispose();
}
}
const _scanWindowMaxSide = 400.0;
const _scanWindowShortestSideRatio = 0.67;
const _hintGap = 24.0;
const _hintInset = 32.0;
const _hintMaxLines = 2;
const _torchSize = 64.0;
const _torchBottomMargin = 48.0;
const _torchSideMargin = 24.0;
const _controlGap = 16.0;
const _edgeMargin = 16.0;
class _ScanLayout {
const _ScanLayout._({
required this.scanWindow,
required this.hint,
required this.torch,
});
// Short viewports, landscape or split screen, cannot stack the window, its
// hint and the torch vertically, so the torch moves beside the window there.
factory _ScanLayout({
required Size size,
required EdgeInsets padding,
required double hintHeight,
}) {
final below = _ScanLayout._torchBelow(size, padding, hintHeight);
final beside = _ScanLayout._torchBeside(size, padding, hintHeight);
return beside.scanWindow.width > below.scanWindow.width ? beside : below;
}
factory _ScanLayout._torchBelow(
Size size,
EdgeInsets padding,
double hintHeight,
) {
final torchTop =
size.height - max(padding.bottom, _torchBottomMargin) - _torchSize;
final scanWindow = _fitScanWindow(
size: size,
top: padding.top + kToolbarHeight,
bottom: torchTop - _controlGap - _hintGap - hintHeight,
maxWidth: size.width,
);
return _ScanLayout._(
scanWindow: scanWindow,
hint: Rect.fromLTWH(
padding.left + _hintInset,
scanWindow.bottom + _hintGap,
max(0, size.width - padding.horizontal - 2 * _hintInset),
hintHeight,
),
torch: Rect.fromLTWH(
padding.left + (size.width - padding.horizontal - _torchSize) / 2,
torchTop,
_torchSize,
_torchSize,
),
);
}
factory _ScanLayout._torchBeside(
Size size,
EdgeInsets padding,
double hintHeight,
) {
final inset =
max(padding.left, padding.right) +
_torchSideMargin +
_torchSize +
_controlGap;
final scanWindow = _fitScanWindow(
size: size,
top: padding.top + _edgeMargin,
bottom:
size.height -
max(padding.bottom, _edgeMargin) -
_hintGap -
hintHeight,
maxWidth: size.width - 2 * inset,
);
final torch = Rect.fromLTWH(
size.width - padding.right - _torchSideMargin - _torchSize,
max(padding.top + kToolbarHeight, scanWindow.center.dy - _torchSize / 2),
_torchSize,
_torchSize,
);
final hintTop = scanWindow.bottom + _hintGap;
final hintInset = torch.bottom <= hintTop
? max(padding.left, padding.right) + _hintInset
: inset;
return _ScanLayout._(
scanWindow: scanWindow,
hint: Rect.fromLTWH(
hintInset,
hintTop,
max(0, size.width - 2 * hintInset),
hintHeight,
),
torch: torch,
);
}
final Rect scanWindow;
final Rect hint;
final Rect torch;
static Rect _fitScanWindow({
required Size size,
required double top,
required double bottom,
required double maxWidth,
}) {
final side = max(
0.0,
[
_scanWindowMaxSide,
size.shortestSide * _scanWindowShortestSideRatio,
bottom - top,
maxWidth,
].reduce(min),
);
final centerY = max(
top + side / 2,
min(size.height / 2, bottom - side / 2),
);
return Rect.fromCenter(
center: Offset(size.width / 2, centerY),
width: side,
height: side,
);
}
}
class _ScannerError extends StatelessWidget {
const _ScannerError({
required this.errorCode,
required this.onRetry,
required this.onOpenSettings,
});
final MobileScannerErrorCode errorCode;
final VoidCallback onRetry;
final VoidCallback onOpenSettings;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final status = switch (errorCode) {
MobileScannerErrorCode.permissionDenied => NullStatus(
label: appLocalizations.cameraPermissionRequired,
description: appLocalizations.cameraPermissionDesc,
illustration: NullStatusIllustration.permission,
action: ElasticButton(
child: FilledButton.tonalIcon(
onPressed: onOpenSettings,
icon: const GlyphIcon(AppGlyphs.settings, fill: 1),
label: Text(appLocalizations.settings),
),
),
),
MobileScannerErrorCode.unsupported => NullStatus(
label: appLocalizations.cameraUnavailable,
illustration: NullStatusIllustration.camera,
),
_ => NullStatus(
label: appLocalizations.cameraUnavailable,
illustration: NullStatusIllustration.camera,
action: ElasticButton(
child: FilledButton.tonalIcon(
onPressed: onRetry,
icon: const GlyphIcon(AppGlyphs.refresh, fill: 1),
label: Text(appLocalizations.retry),
),
),
),
};
final padding =
MediaQuery.paddingOf(context) +
const EdgeInsets.only(top: kToolbarHeight);
// `NullStatus` wraps into a second column once its height is bounded
// below its content, so a short viewport scrolls it instead.
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: padding,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: max(0, constraints.maxHeight - padding.vertical),
),
child: status,
),
),
);
}
}
class _TorchButton extends StatelessWidget {
const _TorchButton({required this.controller});
final MobileScannerController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<MobileScannerState>(
valueListenable: controller,
builder: (context, state, _) {
final torchState = state.torchState;
final available = torchState != TorchState.unavailable;
final lit =
torchState == TorchState.on || torchState == TorchState.auto;
return ActivateBox(
active: available,
child: AnimatedOpacity(
opacity: available ? 1 : 0,
duration: kThemeAnimationDuration,
child: ElasticButton(
child: IconButton.filled(
tooltip: context.appLocalizations.torch,
isSelected: lit,
iconSize: 32,
style: IconButton.styleFrom(fixedSize: const Size.square(64)),
onPressed: controller.toggleTorch,
icon: const GlyphIcon(AppGlyphs.torchOff, fill: 1),
selectedIcon: GlyphIcon(
torchState == TorchState.auto
? AppGlyphs.torchAuto
: AppGlyphs.torch,
fill: 1,
),
),
),
),
);
},
);
}
}
class ScannerOverlay extends CustomPainter {
const ScannerOverlay({
required this.scanWindow,
required this.color,
this.borderRadius = AppCorner.md,
});
final Rect scanWindow;
final Color color;
final double borderRadius;
@override
@@ -193,19 +612,15 @@ class ScannerOverlay extends CustomPainter {
final cutoutPath = Path()
..addRSuperellipse(
RSuperellipse.fromRectAndCorners(
RSuperellipse.fromRectAndRadius(
scanWindow,
topLeft: Radius.circular(borderRadius),
topRight: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
Radius.circular(borderRadius),
),
);
final backgroundPaint = Paint()
..color = Colors.black.opacity50
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
..color = color
..style = PaintingStyle.fill;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
@@ -213,26 +628,175 @@ class ScannerOverlay extends CustomPainter {
cutoutPath,
);
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 4.0;
final border = RSuperellipse.fromRectAndCorners(
scanWindow,
topLeft: Radius.circular(borderRadius),
topRight: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
);
canvas.drawPath(backgroundWithCutout, backgroundPaint);
canvas.drawRSuperellipse(border, borderPaint);
}
@override
bool shouldRepaint(ScannerOverlay oldDelegate) {
return scanWindow != oldDelegate.scanWindow ||
color != oldDelegate.color ||
borderRadius != oldDelegate.borderRadius;
}
}
class _ScanEffectPainter extends CustomPainter {
_ScanEffectPainter({
required this.animation,
required this.scanWindow,
required this.borderRadius,
required this.color,
required this.highlight,
required this.contrast,
}) : super(repaint: animation);
static const _sweepsPerCycle = 2;
static const _gridCells = 18;
final Animation<double> animation;
final Rect scanWindow;
final double borderRadius;
final Color color;
final Color highlight;
final Color contrast;
@override
void paint(Canvas canvas, Size size) {
final t = animation.value;
final window = RSuperellipse.fromRectAndRadius(
scanWindow,
Radius.circular(borderRadius),
);
_paintBorder(canvas, window, t);
canvas.save();
canvas.clipRSuperellipse(window);
_paintBeam(canvas, t);
canvas.restore();
}
void _paintBorder(Canvas canvas, RSuperellipse window, double t) {
Shader comet(Color tail, Color head) => SweepGradient(
colors: [tail, tail, head, tail],
stops: const [0, 0.55, 0.9, 1],
transform: GradientRotation(2 * pi * t),
).createShader(scanWindow);
canvas.drawRSuperellipse(
window,
Paint()
..shader = comet(contrast.opacity0, contrast)
..style = PaintingStyle.stroke
..strokeWidth = 6
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
canvas.drawRSuperellipse(
window,
Paint()
..shader = comet(color, highlight)
..style = PaintingStyle.stroke
..strokeWidth = 2.5,
);
}
void _paintBeam(Canvas canvas, double t) {
final sweep = (t * _sweepsPerCycle) % 1;
final opacity = sweep < 0.1
? sweep / 0.1
: sweep > 0.85
? (1 - sweep) / 0.15
: 1.0;
if (opacity <= 0) {
return;
}
final y =
scanWindow.top + scanWindow.height * Curves.easeInOut.transform(sweep);
final trail = Rect.fromLTRB(
scanWindow.left,
max(scanWindow.top, y - scanWindow.height * 0.45),
scanWindow.right,
y,
);
if (trail.height > 0) {
canvas.saveLayer(trail, Paint());
canvas.drawRect(
trail,
Paint()..shader = _edgeFadeShader(trail, contrast, opacity * 0.24),
);
canvas.drawPath(
_gridPath(trail),
Paint()
..shader = _edgeFadeShader(trail, contrast, opacity * 0.6)
..style = PaintingStyle.stroke
..strokeWidth = 1,
);
canvas.drawRect(
trail,
Paint()
..blendMode = BlendMode.dstIn
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black],
).createShader(trail),
);
canvas.restore();
}
final line = Rect.fromCenter(
center: Offset(scanWindow.center.dx, y),
width: scanWindow.width,
height: 3,
);
canvas.drawRect(
line.inflate(4),
Paint()
..shader = _edgeFadeShader(line, contrast, opacity * 0.8)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
canvas.drawRect(
line,
Paint()..shader = _edgeFadeShader(line, color, opacity),
);
canvas.drawRect(
Rect.fromCenter(center: line.center, width: line.width, height: 1),
Paint()..shader = _edgeFadeShader(line, highlight, opacity),
);
}
Shader _edgeFadeShader(Rect rect, Color color, double opacity) {
const stops = 12;
return LinearGradient(
colors: [
for (var i = 0; i <= stops; i++)
color.withValues(alpha: opacity * sin(pi * i / stops)),
],
).createShader(rect);
}
Path _gridPath(Rect trail) {
final cell = scanWindow.width / _gridCells;
final path = Path();
for (var x = scanWindow.left; x <= scanWindow.right; x += cell) {
path
..moveTo(x, trail.top)
..lineTo(x, trail.bottom);
}
final firstRow = ((trail.top - scanWindow.top) / cell).ceil();
for (
var y = scanWindow.top + firstRow * cell;
y <= trail.bottom;
y += cell
) {
path
..moveTo(trail.left, y)
..lineTo(trail.right, y);
}
return path;
}
@override
bool shouldRepaint(_ScanEffectPainter oldDelegate) {
return animation != oldDelegate.animation ||
scanWindow != oldDelegate.scanWindow ||
borderRadius != oldDelegate.borderRadius ||
color != oldDelegate.color ||
highlight != oldDelegate.highlight;
}
}
+4 -1
View File
@@ -14,8 +14,9 @@ import 'package:fl_clash/providers/actions/system_exit.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' show basename;
import 'package:path/path.dart' show basename, join;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -28,6 +29,8 @@ part 'actions/store.dart';
part 'actions/theme.dart';
part 'actions/proxies.dart';
part 'actions/profiles.dart';
part 'actions/scripts.dart';
part 'actions/clash_providers.dart';
part 'actions/geo_resource.dart';
part 'actions/updating.dart';
part 'generated/action.g.dart';
+125 -33
View File
@@ -12,10 +12,13 @@ class _DelayTestTarget {
final String key;
}
typedef _DelayTestBatch = ({List<Proxy> proxies, String? testUrl});
class _DelayTestJob {
_DelayTestJob(Iterable<String> keys) : held = keys.toSet();
final Set<String> held;
final Set<String> startedOnDispatch = {};
bool cancelled = false;
}
@@ -27,10 +30,19 @@ class ProxiesAction extends _$ProxiesAction {
final List<_DelayTestJob> _delayTestJobs = [];
final List<Delay> _bufferedDelays = [];
final List<String> _bufferedStarts = [];
final List<String> _bufferedFinishes = [];
Timer? _delayFlushTimer;
final Map<String, String> _pendingSelectedRollback = {};
@override
void build() {
ref.onDispose(() => _delayFlushTimer?.cancel());
ref.listen(coreStatusProvider, (_, next) {
if (next != CoreStatus.connected) {
cancelDelayTests();
@@ -43,9 +55,37 @@ class ProxiesAction extends _$ProxiesAction {
job.cancelled = true;
job.held.clear();
}
_delayFlushTimer?.cancel();
_delayFlushTimer = null;
_bufferedDelays.clear();
_bufferedStarts.clear();
_bufferedFinishes.clear();
ref.read(pendingDelayTestsProvider.notifier).clear();
}
void _scheduleDelayFlush() {
_delayFlushTimer ??= Timer(renderThrottleDuration, _flushDelayResults);
}
void _flushDelayResults() {
_delayFlushTimer?.cancel();
_delayFlushTimer = null;
if (_bufferedDelays.isNotEmpty) {
final delays = List.of(_bufferedDelays);
_bufferedDelays.clear();
ref.read(delayDataSourceProvider.notifier).setDelays(delays);
}
if (_bufferedStarts.isNotEmpty || _bufferedFinishes.isNotEmpty) {
final started = List.of(_bufferedStarts);
final finished = List.of(_bufferedFinishes);
_bufferedStarts.clear();
_bufferedFinishes.clear();
ref
.read(pendingDelayTestsProvider.notifier)
.apply(started: started, finished: finished);
}
}
void updateGroupsDebounce([Duration? duration]) {
debouncer.call(FunctionTag.updateGroups, updateGroups, duration: duration);
}
@@ -131,7 +171,8 @@ class ProxiesAction extends _$ProxiesAction {
}
void setDelay(Delay delay) {
ref.read(delayDataSourceProvider.notifier).setDelay(delay);
_bufferedDelays.add(delay);
_scheduleDelayFlush();
}
Future<void> changeProxy({
@@ -143,10 +184,14 @@ class ProxiesAction extends _$ProxiesAction {
_pendingSelectedRollback.remove(groupName) ??
_currentSelectedName(groupName);
profilesAction.updateCurrentSelectedMap(groupName, proxyName);
final ChangeProxyResult result;
try {
await _core.changeProxy(
result = await _core.changeProxy(
ChangeProxyParams(groupName: groupName, proxyName: proxyName),
);
if (result.message.isNotEmpty) {
throw MessageException(result.message);
}
} catch (error) {
commonPrint.log(
'changeProxy($groupName -> $proxyName) failed: $error',
@@ -159,6 +204,9 @@ class ProxiesAction extends _$ProxiesAction {
);
return;
}
if (!result.changed) {
return;
}
try {
if (ref.read(appSettingProvider).closeConnections) {
await _core.closeConnections();
@@ -171,7 +219,6 @@ class ProxiesAction extends _$ProxiesAction {
logLevel: coreFailureLogLevel(error),
);
}
ref.read(checkIpNumProvider.notifier).add();
}
Future<String> updateProvider(
@@ -231,59 +278,93 @@ class ProxiesAction extends _$ProxiesAction {
}
Future<void> proxyDelayTest(Proxy proxy, [String? testUrl]) {
return _runDelayTests([proxy], testUrl);
return _runDelayTests([
(proxies: [proxy], testUrl: testUrl),
]);
}
Future<void> delayTest(List<Proxy> proxies, [String? testUrl]) async {
await _runDelayTests(proxies, testUrl);
await _runDelayTests([(proxies: proxies, testUrl: testUrl)]);
ref.read(sortNumProvider.notifier).add();
}
Future<void> delayTestGroups(List<Group> groups) async {
await _runDelayTests([
for (final group in groups) (proxies: group.all, testUrl: group.testUrl),
]);
ref.read(sortNumProvider.notifier).add();
}
Future<void> delayTestPageGroup(String groupName) async {
final group = ref.read(groupsProvider).getGroup(groupName);
if (group == null) {
return;
}
final testing = ref.read(delayTestingGroupsProvider.notifier);
if (!testing.start(groupName)) {
return;
}
try {
final query = SearchQuery(ref.read(queryProvider(QueryTag.proxies)));
await delayTest(
group.all.whereMatches(query, (proxy) => proxy.searchFields).toList(),
group.testUrl,
);
} finally {
testing.stop(groupName);
}
}
List<_DelayTestTarget> _resolveDelayTestTargets(
List<Proxy> proxies,
String? testUrl,
List<_DelayTestBatch> batches,
) {
final groups = ref.read(groupsProvider);
final selectedMap = ref.read(
currentProfileProvider.select((state) => state?.selectedMap ?? {}),
);
final fallbackTestUrl = ref.read(realTestUrlProvider(testUrl));
final seen = <String>{};
final targets = <_DelayTestTarget>[];
for (final proxy in proxies) {
final state = computeRealSelectedProxyState(
proxy.name,
groups: groups,
selectedMap: selectedMap,
);
if (state.proxyName.isEmpty) {
continue;
for (final batch in batches) {
final fallbackTestUrl = ref.read(realTestUrlProvider(batch.testUrl));
for (final proxy in batch.proxies) {
final state = computeRealSelectedProxyState(
proxy.name,
groups: groups,
selectedMap: selectedMap,
);
if (state.proxyName.isEmpty) {
continue;
}
final currentTestUrl = state.testUrl.takeFirstValid([fallbackTestUrl]);
final key = delayTestKey(currentTestUrl, state.proxyName);
if (!seen.add(key)) {
continue;
}
targets.add(
_DelayTestTarget(
proxyName: state.proxyName,
testUrl: currentTestUrl,
key: key,
),
);
}
final currentTestUrl = state.testUrl.takeFirstValid([fallbackTestUrl]);
final key = delayTestKey(currentTestUrl, state.proxyName);
if (!seen.add(key)) {
continue;
}
targets.add(
_DelayTestTarget(
proxyName: state.proxyName,
testUrl: currentTestUrl,
key: key,
),
);
}
return targets;
}
Future<void> _runDelayTests(List<Proxy> proxies, String? testUrl) async {
final targets = _resolveDelayTestTargets(proxies, testUrl);
Future<void> _runDelayTests(List<_DelayTestBatch> batches) async {
final targets = _resolveDelayTestTargets(batches);
if (targets.isEmpty) {
return;
}
final pending = ref.read(pendingDelayTestsProvider.notifier);
final job = _DelayTestJob(targets.map((target) => target.key));
_delayTestJobs.add(job);
pending.acquire(job.held);
// TaskPool.run starts a task synchronously while it has an idle slot.
job.startedOnDispatch.addAll(
targets.take(_delayTestPool.idleSlots).map((target) => target.key),
);
pending.apply(acquired: job.held, started: job.startedOnDispatch);
try {
await Future.wait(
targets.map(
@@ -292,9 +373,15 @@ class ProxiesAction extends _$ProxiesAction {
);
} finally {
_delayTestJobs.remove(job);
_flushDelayResults();
final abandoned = job.held.toList();
job.held.clear();
pending.release(abandoned);
pending.apply(
finished: abandoned.where(job.startedOnDispatch.contains),
released: abandoned.where(
(key) => !job.startedOnDispatch.contains(key),
),
);
}
}
@@ -302,6 +389,10 @@ class ProxiesAction extends _$ProxiesAction {
if (job.cancelled) {
return;
}
if (!job.startedOnDispatch.contains(target.key)) {
_bufferedStarts.add(target.key);
_scheduleDelayFlush();
}
try {
final delay = await _core.getDelay(target.testUrl, target.proxyName);
if (delay != null && !job.cancelled) {
@@ -317,7 +408,8 @@ class ProxiesAction extends _$ProxiesAction {
);
} finally {
if (job.held.remove(target.key)) {
ref.read(pendingDelayTestsProvider.notifier).release([target.key]);
_bufferedFinishes.add(target.key);
_scheduleDelayFlush();
}
}
}
+135 -34
View File
@@ -28,12 +28,20 @@ class SetupAction extends _$SetupAction {
@override
void build() {
ref.onDispose(() {
_runtimeTimer?.cancel();
_runtimeTimer = null;
});
ref.onDispose(_stopRuntimeTicker);
ref.listen(appVisibleProvider, (_, _) => _syncRuntimeTicker());
ref.listen(
appSettingProvider.select((state) => state.showTrayTitle),
(_, _) => _syncRuntimeTicker(),
);
}
bool get _isTrafficShown =>
ref.read(appVisibleProvider) ||
(system.isMacOS &&
!ref.read(safeModeProvider) &&
ref.read(appSettingProvider).showTrayTitle);
SetupParams get _setupParams {
final selectedMap = ref.read(selectedMapProvider);
final testUrl = ref.read(
@@ -49,6 +57,11 @@ class SetupAction extends _$SetupAction {
final setupResult = applyProfile(force: true);
ref.read(logsProvider.notifier).value = FixedList(maxLogsLength);
ref.read(requestsProvider.notifier).value = FixedList(maxRequestsLength);
ref.read(dnsQueriesProvider.notifier).value = FixedList(
maxDnsQueriesLength,
);
ref.read(dnsQueryCountProvider.notifier).value = 0;
ref.read(requestCountProvider.notifier).value = 0;
try {
return await setupResult;
} catch (e, s) {
@@ -58,8 +71,7 @@ class SetupAction extends _$SetupAction {
}
void _setLocalRunning(bool running) {
_runtimeTimer?.cancel();
_runtimeTimer = null;
_stopRuntimeTicker();
if (!running) {
_startTime = null;
debouncer.cancel(FunctionTag.applyProfile);
@@ -68,6 +80,18 @@ class SetupAction extends _$SetupAction {
}
_startTime ??= DateTime.now();
_updateRunTime();
_syncRuntimeTicker();
}
void _syncRuntimeTicker() {
if (_startTime == null || !_isTrafficShown) {
_stopRuntimeTicker();
return;
}
if (_runtimeTimer != null) {
return;
}
_refreshRunningState();
_runtimeTimer = Timer.periodic(
const Duration(seconds: 1),
@@ -75,6 +99,11 @@ class SetupAction extends _$SetupAction {
);
}
void _stopRuntimeTicker() {
_runtimeTimer?.cancel();
_runtimeTimer = null;
}
void _refreshRunningState() {
_updateRunTime();
unawaited(ref.read(commonActionProvider.notifier).updateTraffic());
@@ -169,7 +198,6 @@ class SetupAction extends _$SetupAction {
resetCoreTraffic();
ref.read(trafficsProvider.notifier).clear();
ref.read(totalTrafficProvider.notifier).value = const Traffic();
ref.read(checkIpNumProvider.notifier).add();
return true;
}
@@ -181,6 +209,9 @@ class SetupAction extends _$SetupAction {
if (request.running && ref.read(suspendProvider)) {
return;
}
if (ref.read(safeModeProvider)) {
return;
}
await setCoreRunning(request.running);
});
}
@@ -212,32 +243,23 @@ class SetupAction extends _$SetupAction {
@visibleForTesting
Future<void> updateConfig() async {
await globalState.safeRun(() async {
final updateParams = ref.read(updateParamsProvider);
final shouldContinueSetup = await requestAdmin(updateParams.tun.enable);
final patchConfig = ref.read(patchClashConfigProvider);
final shouldContinueSetup = await requestAdmin(patchConfig.tun.enable);
if (!shouldContinueSetup) {
await _restartCoreAfterAuthorization();
return;
}
final networkSetting = ref.read(networkSettingProvider);
final message = await _core.updateConfig(
updateParams.copyWith.tun(
enable: _getEffectiveTunEnable(updateParams.tun.enable),
_effectivePatchConfig(patchConfig).toUpdateParams(
routeMode: networkSetting.routeMode,
authentication: networkSetting.authentication.credentials,
),
);
ref.read(checkIpNumProvider.notifier).add();
if (message.isNotEmpty) throw MessageException(message);
});
}
void tryCheckIp() {
final isTimeout = ref.read(
networkDetectionProvider.select(
(state) => state.ipInfo == null && state.isLoading == false,
),
);
if (!isTimeout) return;
ref.read(checkIpNumProvider.notifier).add();
}
void applyProfileDebounce({bool silence = false, bool force = false}) {
debouncer.call(FunctionTag.applyProfile, (silence, force) {
applyProfile(silence: silence, force: force);
@@ -256,9 +278,7 @@ class SetupAction extends _$SetupAction {
}
void autoApplyProfile() {
WidgetsBinding.instance.addPostFrameCallback((_) {
applyProfile();
});
runAfterFrame(applyProfile);
}
// False means building the profile, the config write, or the Core setup
@@ -329,11 +349,13 @@ class SetupAction extends _$SetupAction {
),
);
final overrideDns = ref.read(overrideDnsProvider);
final overrideNtp = ref.read(overrideNtpProvider);
final appendSystemDns = networkSetting.appendSystemDns;
final routeMode = networkSetting.routeMode;
final configMap = await _core.getConfig(profileId);
String? scriptContent;
final List<Rule> addedRules = [];
final List<CustomProxy> proxies = [];
final List<ProxyGroup> proxyGroups = [];
final List<Rule> rules = [];
if (setupState.overwriteType == OverwriteType.script) {
@@ -341,6 +363,7 @@ class SetupAction extends _$SetupAction {
} else if (setupState.overwriteType == OverwriteType.standard) {
addedRules.addAll(setupState.addedRules);
} else {
proxies.addAll(setupState.customProxies);
proxyGroups.addAll(setupState.proxyGroups);
rules.addAll(setupState.rules);
}
@@ -352,25 +375,95 @@ class SetupAction extends _$SetupAction {
rawConfig = await handleEvaluate(scriptContent!, rawConfig);
}
final directory = await appPath.profilesPath;
final injected = await _resolveInjectedProviders(
setupState,
proxyGroups: proxyGroups,
rules: rules,
);
final res = makeRealProfileTask(
MakeRealProfileState(
rules: rules,
proxies: proxies,
proxyGroups: proxyGroups,
injectedProxyProviders: injected.proxies,
injectedRuleProviders: injected.rules,
profilesPath: directory,
profileId: profileId,
rawConfig: rawConfig,
realPatchConfig: realPatchConfig,
overrideDns: overrideDns,
overrideNtp: overrideNtp,
appendSystemDns: appendSystemDns,
addedRules: addedRules,
defaultUA: defaultUA,
authentication: networkSetting.authentication.credentials,
matchTarget: setupState.matchTarget,
safeMode: ref.read(safeModeProvider),
),
);
return res;
}
/// Only what the overwrite actually references is injected, so an app-level
/// provider costs nothing in the profiles that never name it.
Future<({Map<String, dynamic> proxies, Map<String, dynamic> rules})>
_resolveInjectedProviders(
SetupState setupState, {
required List<ProxyGroup> proxyGroups,
required List<Rule> rules,
}) async {
final usedProxyProviders = <String>{
for (final proxyGroup in proxyGroups) ...?proxyGroup.use,
};
final usedRuleProviders = <String>{
for (final rule in rules)
if (rule.ruleAction == RuleAction.RULE_SET && rule.ruleProvider != null)
rule.ruleProvider!,
};
if (usedProxyProviders.isEmpty && usedRuleProviders.isEmpty) {
return (
proxies: const <String, dynamic>{},
rules: const <String, dynamic>{},
);
}
final proxies = <String, dynamic>{};
for (final entry in setupState.profileProviders.entries) {
if (!usedProxyProviders.contains(entry.key)) {
continue;
}
proxies[entry.key] = {
'type': 'file',
'path': await appPath.getProfilePath(entry.value.toString()),
};
}
final ruleProviders = <String, dynamic>{};
for (final provider in setupState.clashProviders) {
final used = switch (provider.kind) {
ProviderKind.proxy => usedProxyProviders,
ProviderKind.rule => usedRuleProviders,
};
if (!used.contains(provider.label)) {
continue;
}
if (provider.kind == ProviderKind.proxy &&
proxies.containsKey(provider.label)) {
commonPrint.log(
'proxy provider ${provider.label} shadowed by the profile of that name',
logLevel: LogLevel.warning,
);
continue;
}
final definition = provider.definition(await provider.path);
switch (provider.kind) {
case ProviderKind.proxy:
proxies[provider.label] = definition;
case ProviderKind.rule:
ruleProviders[provider.label] = definition;
}
}
return (proxies: proxies, rules: ruleProviders);
}
Future<String> getProfileWithId(int profileId) async {
try {
final setupState = await ref.read(setupStateProvider(profileId).future);
@@ -386,9 +479,21 @@ class SetupAction extends _$SetupAction {
return '';
}
bool _getEffectiveTunEnable(bool enableTun) {
final authorizationState = ref.read(authorizedTunEnableProvider);
return enableTun && authorizationState == TunAuthorizationState.authorized;
PatchClashConfig _effectivePatchConfig(PatchClashConfig patchConfig) {
if (ref.read(safeModeProvider)) {
return patchConfig.copyWith(
tun: patchConfig.tun.copyWith(enable: false),
externalController: ExternalControllerStatus.close,
);
}
final authorized =
ref.read(authorizedTunEnableProvider) ==
TunAuthorizationState.authorized;
return patchConfig.copyWith(
tun: patchConfig.tun.copyWith(
enable: patchConfig.tun.enable && authorized,
),
);
}
@protected
@@ -398,7 +503,7 @@ class SetupAction extends _$SetupAction {
@visibleForTesting
Future<bool> requestAdmin(bool enableTun) async {
if (!enableTun) {
if (!enableTun || ref.read(safeModeProvider)) {
return true;
}
final authorizationState = ref.read(authorizedTunEnableProvider);
@@ -465,10 +570,7 @@ class SetupAction extends _$SetupAction {
if (!shouldContinueSetup) {
return _SetupTaskResult.handoffToCoreRestart;
}
final effectiveTunEnable = _getEffectiveTunEnable(patchConfig.tun.enable);
final realPatchConfig = patchConfig.copyWith.tun(
enable: effectiveTunEnable,
);
final realPatchConfig = _effectivePatchConfig(patchConfig);
final realProfile = await globalState.safeRun(() async {
final setupState = await ref.read(setupStateProvider(profile?.id).future);
return getProfile(setupState: setupState, patchConfig: realPatchConfig);
@@ -511,7 +613,6 @@ class SetupAction extends _$SetupAction {
rethrow;
}
globalState.lastConfigMd5 = yamlMd5;
ref.read(checkIpNumProvider.notifier).add();
await onUpdated?.call();
},
silence: true,
+186 -126
View File
@@ -1,19 +1,21 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:dio/dio.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/core.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
part 'generated/app.g.dart';
@Riverpod(keepAlive: true)
bool safeMode(Ref ref) => safeModeBuild;
@Riverpod(keepAlive: true)
class AuthorizedTunEnable extends _$AuthorizedTunEnable
with AutoDisposeNotifierMixin {
@@ -63,6 +65,37 @@ class Requests extends _$Requests with AutoDisposeNotifierMixin {
}
}
@Riverpod(keepAlive: true)
class DnsQueries extends _$DnsQueries with AutoDisposeNotifierMixin {
@override
FixedList<DnsQuery> build() {
return FixedList(maxDnsQueriesLength);
}
void addQuery(DnsQuery value) {
if (!ref.mounted) {
return;
}
this.value = state.append(value);
}
}
@Riverpod(keepAlive: true)
class DnsQueryCount extends _$DnsQueryCount with AutoDisposeNotifierMixin {
@override
int build() {
return 0;
}
}
@Riverpod(keepAlive: true)
class RequestCount extends _$RequestCount with AutoDisposeNotifierMixin {
@override
int build() {
return 0;
}
}
@Riverpod(keepAlive: true)
class Providers extends _$Providers with AutoDisposeNotifierMixin {
@override
@@ -151,6 +184,16 @@ class RunTime extends _$RunTime with AutoDisposeNotifierMixin {
}
}
/// False while the window is hidden or the Android activity is in the
/// background; work that only feeds the UI waits for it to come back.
@Riverpod(keepAlive: true)
class AppVisible extends _$AppVisible with AutoDisposeNotifierMixin {
@override
bool build() {
return true;
}
}
@Riverpod(keepAlive: true)
class ViewSize extends _$ViewSize with AutoDisposeNotifierMixin {
@override
@@ -167,6 +210,15 @@ class SideWidth extends _$SideWidth with AutoDisposeNotifierMixin {
}
}
/// Whether the native window currently draws a backdrop behind the content.
@Riverpod(keepAlive: true)
class WindowBlur extends _$WindowBlur with AutoDisposeNotifierMixin {
@override
bool build() {
return false;
}
}
@Riverpod(keepAlive: true)
double viewWidth(Ref ref) {
return ref.watch(viewSizeProvider).width;
@@ -222,16 +274,6 @@ class SortNum extends _$SortNum with AutoDisposeNotifierMixin {
int add() => state++;
}
@Riverpod(keepAlive: true)
class CheckIpNum extends _$CheckIpNum with AutoDisposeNotifierMixin {
@override
int build() {
return 0;
}
int add() => state++;
}
@Riverpod(keepAlive: true)
class Version extends _$Version with AutoDisposeNotifierMixin {
@override
@@ -250,74 +292,173 @@ class Groups extends _$Groups with AutoDisposeNotifierMixin {
@Riverpod(keepAlive: true)
class DelayDataSource extends _$DelayDataSource with AutoDisposeNotifierMixin {
DelayMap? _owned;
DelayMap? _published;
@override
DelayMap build() {
return {};
}
void setDelay(Delay delay) {
if (state[delay.url]?[delay.name] != delay.value) {
final DelayMap newDelayMap = Map.from(state);
if (newDelayMap[delay.url] == null) {
newDelayMap[delay.url] = {};
setDelays([delay]);
}
/// Publishes a live view, so a dependent that keeps the map must copy it.
void setDelays(Iterable<Delay> delays) {
final delayMap = _ownedDelayMap();
var changed = false;
for (final delay in delays) {
if (delayMap[delay.url]?[delay.name] == delay.value) {
continue;
}
newDelayMap[delay.url]![delay.name] = delay.value;
value = newDelayMap;
(delayMap[delay.url] ??= {})[delay.name] = delay.value;
changed = true;
}
if (changed) {
value = _published = UnmodifiableMapView(delayMap);
}
}
DelayMap _ownedDelayMap() {
final owned = _owned;
if (owned != null && identical(state, _published)) {
return owned;
}
return _owned = {
for (final entry in state.entries) entry.key: {...entry.value},
};
}
}
@Riverpod(keepAlive: true)
class PendingDelayTests extends _$PendingDelayTests
with AutoDisposeNotifierMixin {
final Map<String, int> _counts = {};
final Map<String, int> _holds = {};
final Map<String, int> _runs = {};
final Map<String, DelayTestPhase> _phases = {};
@override
Set<String> build() {
return const <String>{};
Map<String, DelayTestPhase> build() {
return const <String, DelayTestPhase>{};
}
void acquire(Iterable<String> keys) {
var added = false;
for (final key in keys) {
final count = _counts[key] ?? 0;
_counts[key] = count + 1;
added |= count == 0;
/// [finished] keys had started, so they give up their run as well as their
/// hold; [released] keys never started.
void apply({
Iterable<String> acquired = const [],
Iterable<String> started = const [],
Iterable<String> finished = const [],
Iterable<String> released = const [],
}) {
final touched = <String>{};
for (final key in acquired) {
_holds[key] = (_holds[key] ?? 0) + 1;
touched.add(key);
}
if (added) {
_publish();
for (final key in started) {
if (_holds.containsKey(key)) {
_runs[key] = (_runs[key] ?? 0) + 1;
touched.add(key);
}
}
}
void release(Iterable<String> keys) {
var removed = false;
for (final key in keys) {
final count = _counts[key];
if (count == null) {
for (final key in finished) {
_decrement(_runs, key);
_decrement(_holds, key);
touched.add(key);
}
for (final key in released) {
_decrement(_holds, key);
touched.add(key);
}
var changed = false;
for (final key in touched) {
final phase = !_holds.containsKey(key)
? null
: _runs.containsKey(key)
? DelayTestPhase.running
: DelayTestPhase.queued;
if (_phases[key] == phase) {
continue;
}
if (count > 1) {
_counts[key] = count - 1;
continue;
if (phase == null) {
_phases.remove(key);
} else {
_phases[key] = phase;
}
_counts.remove(key);
removed = true;
changed = true;
}
if (removed) {
if (changed) {
_publish();
}
}
void clear() {
if (_counts.isEmpty) {
if (_holds.isEmpty) {
return;
}
_counts.clear();
_holds.clear();
_runs.clear();
_phases.clear();
_publish();
}
void _decrement(Map<String, int> counts, String key) {
final count = counts[key];
if (count == null) {
return;
}
if (count > 1) {
counts[key] = count - 1;
} else {
counts.remove(key);
}
}
// A copy per publish would make a run over n nodes O(n²). The view is live,
// so a dependent selects what it needs instead of keeping the map.
void _publish() {
value = Set.unmodifiable(_counts.keys);
value = UnmodifiableMapView(_phases);
}
}
@Riverpod(keepAlive: true)
class DelayTestingGroups extends _$DelayTestingGroups
with AutoDisposeNotifierMixin {
@override
Set<String> build() {
return const <String>{};
}
bool start(String groupName) {
if (value.contains(groupName)) {
return false;
}
value = {...value, groupName};
return true;
}
void stop(String groupName) {
if (!value.contains(groupName)) {
return;
}
value = {...value}..remove(groupName);
}
}
@Riverpod(keepAlive: true)
class HotKeyFailures extends _$HotKeyFailures with AutoDisposeNotifierMixin {
@override
Map<HotAction, String> build() {
return const {};
}
}
@Riverpod(keepAlive: true)
class HotKeyRecording extends _$HotKeyRecording with AutoDisposeNotifierMixin {
@override
bool build() {
return false;
}
}
@@ -472,86 +613,6 @@ bool isUpdating(Ref ref, String name) {
return ref.watch(updatingKeysProvider).contains(name);
}
@Riverpod(keepAlive: true)
class NetworkDetection extends _$NetworkDetection
with AutoDisposeNotifierMixin {
static const _timeoutDisplayDelay = Duration(seconds: 2);
bool? _preIsStart;
CancelToken? _cancelToken;
Timer? _timeoutTimer;
int _checkVersion = 0;
@override
NetworkDetectionState build() {
ref.onDispose(() {
_resetCheckSession(null);
});
return const NetworkDetectionState(isLoading: true, ipInfo: null);
}
void startCheck() {
debouncer.call(FunctionTag.checkIp, () {
_checkIp();
}, duration: commonDuration);
}
Future<void> _checkIp() async {
final isInit = ref.read(initProvider);
if (!isInit) {
return;
}
final isStart = ref.read(isStartProvider);
if (!isStart && _preIsStart == false && state.ipInfo != null) {
return;
}
final cancelToken = CancelToken();
final version = _resetCheckSession(cancelToken);
commonPrint.log('checkIp start');
state = state.copyWith(isLoading: true, ipInfo: null);
_preIsStart = isStart;
final res = await request.checkIp(cancelToken: cancelToken);
commonPrint.log('checkIp res: $res');
if (!ref.mounted ||
version != _checkVersion ||
cancelToken != _cancelToken) {
return;
}
final ipInfo = res.data;
if (ipInfo == null) {
_delayTimeoutDisplay(version);
return;
}
state = state.copyWith(isLoading: false, ipInfo: ipInfo);
}
int _resetCheckSession(CancelToken? cancelToken) {
_cancelTimeoutTimer();
final version = ++_checkVersion;
final previousCancelToken = _cancelToken;
_cancelToken = cancelToken;
previousCancelToken?.cancel();
return version;
}
void _delayTimeoutDisplay(int version) {
_cancelTimeoutTimer();
_timeoutTimer = Timer(_timeoutDisplayDelay, () {
_timeoutTimer = null;
if (!ref.mounted || version != _checkVersion || state.ipInfo != null) {
return;
}
state = state.copyWith(isLoading: false, ipInfo: null);
});
}
void _cancelTimeoutTimer() {
_timeoutTimer?.cancel();
_timeoutTimer = null;
}
}
@Riverpod(keepAlive: true)
class CurrentSSID extends _$CurrentSSID with AutoDisposeNotifierMixin {
@override
@@ -588,7 +649,6 @@ List<Override> buildAppStateOverrides(AppState appState) {
sideWidthProvider.overrideWithBuild((_, _) => appState.sideWidth),
delayDataSourceProvider.overrideWithBuild((_, _) => appState.delayMap),
groupsProvider.overrideWithBuild((_, _) => appState.groups),
checkIpNumProvider.overrideWithBuild((_, _) => appState.checkIpNum),
systemBrightnessProvider.overrideWithBuild((_, _) => appState.brightness),
runTimeProvider.overrideWithBuild((_, _) => appState.runTime),
providersProvider.overrideWithBuild((_, _) => appState.providers),
+112 -7
View File
@@ -91,7 +91,7 @@ final class SetupActionProvider extends $NotifierProvider<SetupAction, void> {
}
}
String _$setupActionHash() => r'1ad2b0edb6bb505c638c6982020932351e8fd53c';
String _$setupActionHash() => r'4f66bcba4d60a9befa5f2240e096f87342a941d1';
abstract class _$SetupAction extends $Notifier<void> {
void build();
@@ -142,7 +142,7 @@ final class BackupActionProvider extends $NotifierProvider<BackupAction, void> {
}
}
String _$backupActionHash() => r'ffca3a6f1f1b96c61561412e99b2410c7660311f';
String _$backupActionHash() => r'60f45e769abdd98fa5b6c1bc9f4218ae32160eb7';
abstract class _$BackupAction extends $Notifier<void> {
void build();
@@ -244,7 +244,7 @@ final class SystemActionProvider extends $NotifierProvider<SystemAction, void> {
}
}
String _$systemActionHash() => r'89be5d3bd8143e3e34f04c4811053bce16e590e7';
String _$systemActionHash() => r'e2608d91ecdb7f06d593eb85534fc49b247fac41';
abstract class _$SystemAction extends $Notifier<void> {
void build();
@@ -295,7 +295,7 @@ final class StoreActionProvider extends $NotifierProvider<StoreAction, void> {
}
}
String _$storeActionHash() => r'09116828bb7abbb75d54e0d3f8d842104b8d5790';
String _$storeActionHash() => r'1985c9fff7477a309a9f121d51fdfaf228e7bde1';
abstract class _$StoreAction extends $Notifier<void> {
void build();
@@ -346,7 +346,7 @@ final class ThemeActionProvider extends $NotifierProvider<ThemeAction, void> {
}
}
String _$themeActionHash() => r'9802c7ba8247f8c2d396fab398642567d8eecf62';
String _$themeActionHash() => r'f33484cafdb1c8dec472f86f03ed4136c12770a0';
abstract class _$ThemeAction extends $Notifier<void> {
void build();
@@ -398,7 +398,7 @@ final class ProxiesActionProvider
}
}
String _$proxiesActionHash() => r'd25f2cc46754b67fb1f224b42625be36c9818edb';
String _$proxiesActionHash() => r'6792901e36ed8d74fc5b6d761bbe5057b26ef779';
abstract class _$ProxiesAction extends $Notifier<void> {
void build();
@@ -450,7 +450,7 @@ final class ProfilesActionProvider
}
}
String _$profilesActionHash() => r'639d2bfc31789c39e63101e9b9b39f619166ae6f';
String _$profilesActionHash() => r'cf34bb826838abe3265febd9f8720e28649fb1fd';
abstract class _$ProfilesAction extends $Notifier<void> {
void build();
@@ -470,6 +470,111 @@ abstract class _$ProfilesAction extends $Notifier<void> {
}
}
@ProviderFor(ScriptsAction)
final scriptsActionProvider = ScriptsActionProvider._();
final class ScriptsActionProvider
extends $NotifierProvider<ScriptsAction, void> {
ScriptsActionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'scriptsActionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$scriptsActionHash();
@$internal
@override
ScriptsAction create() => ScriptsAction();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$scriptsActionHash() => r'69ef0800ba3bb8c7b5de44b772c2421ac06c71da';
abstract class _$ScriptsAction extends $Notifier<void> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(ClashProvidersAction)
final clashProvidersActionProvider = ClashProvidersActionProvider._();
final class ClashProvidersActionProvider
extends $NotifierProvider<ClashProvidersAction, void> {
ClashProvidersActionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'clashProvidersActionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clashProvidersActionHash();
@$internal
@override
ClashProvidersAction create() => ClashProvidersAction();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$clashProvidersActionHash() =>
r'b139fd15cdc918740a6048722b936735661ecdac';
abstract class _$ClashProvidersAction extends $Notifier<void> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(GeoResourceAction)
final geoResourceActionProvider = GeoResourceActionProvider._();
+481 -107
View File
@@ -9,6 +9,46 @@ part of '../app.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(safeMode)
final safeModeProvider = SafeModeProvider._();
final class SafeModeProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
SafeModeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'safeModeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$safeModeHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return safeMode(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$safeModeHash() => r'3ef07344f5a8dbf775ccf0a4861c0317ec8b2266';
@ProviderFor(AuthorizedTunEnable)
final authorizedTunEnableProvider = AuthorizedTunEnableProvider._();
@@ -166,6 +206,161 @@ abstract class _$Requests extends $Notifier<FixedList<TrackerInfo>> {
}
}
@ProviderFor(DnsQueries)
final dnsQueriesProvider = DnsQueriesProvider._();
final class DnsQueriesProvider
extends $NotifierProvider<DnsQueries, FixedList<DnsQuery>> {
DnsQueriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dnsQueriesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dnsQueriesHash();
@$internal
@override
DnsQueries create() => DnsQueries();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FixedList<DnsQuery> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FixedList<DnsQuery>>(value),
);
}
}
String _$dnsQueriesHash() => r'b6073d067db4fd19cc5724c5b79ecff2b0909796';
abstract class _$DnsQueries extends $Notifier<FixedList<DnsQuery>> {
FixedList<DnsQuery> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<FixedList<DnsQuery>, FixedList<DnsQuery>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<FixedList<DnsQuery>, FixedList<DnsQuery>>,
FixedList<DnsQuery>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(DnsQueryCount)
final dnsQueryCountProvider = DnsQueryCountProvider._();
final class DnsQueryCountProvider
extends $NotifierProvider<DnsQueryCount, int> {
DnsQueryCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dnsQueryCountProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dnsQueryCountHash();
@$internal
@override
DnsQueryCount create() => DnsQueryCount();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$dnsQueryCountHash() => r'2de3bcb1c2fd622af728158bd9a1e169af632395';
abstract class _$DnsQueryCount extends $Notifier<int> {
int build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<int, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<int, int>,
int,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(RequestCount)
final requestCountProvider = RequestCountProvider._();
final class RequestCountProvider extends $NotifierProvider<RequestCount, int> {
RequestCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'requestCountProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$requestCountHash();
@$internal
@override
RequestCount create() => RequestCount();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$requestCountHash() => r'aedb813c287a83a2588517f6c5c13e3f8089e0bc';
abstract class _$RequestCount extends $Notifier<int> {
int build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<int, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<int, int>,
int,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(Providers)
final providersProvider = ProvidersProvider._();
@@ -581,6 +776,67 @@ abstract class _$RunTime extends $Notifier<int?> {
}
}
/// False while the window is hidden or the Android activity is in the
/// background; work that only feeds the UI waits for it to come back.
@ProviderFor(AppVisible)
final appVisibleProvider = AppVisibleProvider._();
/// False while the window is hidden or the Android activity is in the
/// background; work that only feeds the UI waits for it to come back.
final class AppVisibleProvider extends $NotifierProvider<AppVisible, bool> {
/// False while the window is hidden or the Android activity is in the
/// background; work that only feeds the UI waits for it to come back.
AppVisibleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appVisibleProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appVisibleHash();
@$internal
@override
AppVisible create() => AppVisible();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$appVisibleHash() => r'98f3d41169c74da3d8655695627c8e4a5413c73d';
/// False while the window is hidden or the Android activity is in the
/// background; work that only feeds the UI waits for it to come back.
abstract class _$AppVisible extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(ViewSize)
final viewSizeProvider = ViewSizeProvider._();
@@ -683,6 +939,63 @@ abstract class _$SideWidth extends $Notifier<double> {
}
}
/// Whether the native window currently draws a backdrop behind the content.
@ProviderFor(WindowBlur)
final windowBlurProvider = WindowBlurProvider._();
/// Whether the native window currently draws a backdrop behind the content.
final class WindowBlurProvider extends $NotifierProvider<WindowBlur, bool> {
/// Whether the native window currently draws a backdrop behind the content.
WindowBlurProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'windowBlurProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$windowBlurHash();
@$internal
@override
WindowBlur create() => WindowBlur();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$windowBlurHash() => r'1009d8da62ada83f4afc379063d69413d359821b';
/// Whether the native window currently draws a backdrop behind the content.
abstract class _$WindowBlur extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(viewWidth)
final viewWidthProvider = ViewWidthProvider._();
@@ -1000,57 +1313,6 @@ abstract class _$SortNum extends $Notifier<int> {
}
}
@ProviderFor(CheckIpNum)
final checkIpNumProvider = CheckIpNumProvider._();
final class CheckIpNumProvider extends $NotifierProvider<CheckIpNum, int> {
CheckIpNumProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'checkIpNumProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$checkIpNumHash();
@$internal
@override
CheckIpNum create() => CheckIpNum();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$checkIpNumHash() => r'e66b46fae31f3683698dc55533fbdd240aff44fe';
abstract class _$CheckIpNum extends $Notifier<int> {
int build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<int, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<int, int>,
int,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(Version)
final versionProvider = VersionProvider._();
@@ -1185,7 +1447,7 @@ final class DelayDataSourceProvider
}
}
String _$delayDataSourceHash() => r'9737cf2d943cb9b5504a5ec8ace20b0a9380b197';
String _$delayDataSourceHash() => r'0d0fb2faccb3ce4539ce683b1a2bc4dcb8c8f3c0';
abstract class _$DelayDataSource extends $Notifier<DelayMap> {
DelayMap build();
@@ -1209,7 +1471,7 @@ abstract class _$DelayDataSource extends $Notifier<DelayMap> {
final pendingDelayTestsProvider = PendingDelayTestsProvider._();
final class PendingDelayTestsProvider
extends $NotifierProvider<PendingDelayTests, Set<String>> {
extends $NotifierProvider<PendingDelayTests, Map<String, DelayTestPhase>> {
PendingDelayTestsProvider._()
: super(
from: null,
@@ -1228,6 +1490,64 @@ final class PendingDelayTestsProvider
@override
PendingDelayTests create() => PendingDelayTests();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, DelayTestPhase> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, DelayTestPhase>>(value),
);
}
}
String _$pendingDelayTestsHash() => r'e1e306c07e3ee017fe87ffd2f93884418b6caa03';
abstract class _$PendingDelayTests
extends $Notifier<Map<String, DelayTestPhase>> {
Map<String, DelayTestPhase> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<Map<String, DelayTestPhase>, Map<String, DelayTestPhase>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<String, DelayTestPhase>,
Map<String, DelayTestPhase>
>,
Map<String, DelayTestPhase>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(DelayTestingGroups)
final delayTestingGroupsProvider = DelayTestingGroupsProvider._();
final class DelayTestingGroupsProvider
extends $NotifierProvider<DelayTestingGroups, Set<String>> {
DelayTestingGroupsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'delayTestingGroupsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$delayTestingGroupsHash();
@$internal
@override
DelayTestingGroups create() => DelayTestingGroups();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Set<String> value) {
return $ProviderOverride(
@@ -1237,9 +1557,10 @@ final class PendingDelayTestsProvider
}
}
String _$pendingDelayTestsHash() => r'ceaac9e42e13d87a1885d2f227971e34665b18ca';
String _$delayTestingGroupsHash() =>
r'3a56d623f940786303d78590b18440bcd007fab4';
abstract class _$PendingDelayTests extends $Notifier<Set<String>> {
abstract class _$DelayTestingGroups extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
@@ -1257,6 +1578,111 @@ abstract class _$PendingDelayTests extends $Notifier<Set<String>> {
}
}
@ProviderFor(HotKeyFailures)
final hotKeyFailuresProvider = HotKeyFailuresProvider._();
final class HotKeyFailuresProvider
extends $NotifierProvider<HotKeyFailures, Map<HotAction, String>> {
HotKeyFailuresProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'hotKeyFailuresProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$hotKeyFailuresHash();
@$internal
@override
HotKeyFailures create() => HotKeyFailures();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<HotAction, String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<HotAction, String>>(value),
);
}
}
String _$hotKeyFailuresHash() => r'ab0d31dd41cae6a1445461c5a978809eee350509';
abstract class _$HotKeyFailures extends $Notifier<Map<HotAction, String>> {
Map<HotAction, String> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref as $Ref<Map<HotAction, String>, Map<HotAction, String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<HotAction, String>, Map<HotAction, String>>,
Map<HotAction, String>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(HotKeyRecording)
final hotKeyRecordingProvider = HotKeyRecordingProvider._();
final class HotKeyRecordingProvider
extends $NotifierProvider<HotKeyRecording, bool> {
HotKeyRecordingProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'hotKeyRecordingProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$hotKeyRecordingHash();
@$internal
@override
HotKeyRecording create() => HotKeyRecording();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$hotKeyRecordingHash() => r'8cbbdb394fcc10ae564e4e91696668cc8a327507';
abstract class _$HotKeyRecording extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(SystemUiOverlayStyleState)
final systemUiOverlayStyleStateProvider = SystemUiOverlayStyleStateProvider._();
@@ -1852,58 +2278,6 @@ final class IsUpdatingFamily extends $Family
String toString() => r'isUpdatingProvider';
}
@ProviderFor(NetworkDetection)
final networkDetectionProvider = NetworkDetectionProvider._();
final class NetworkDetectionProvider
extends $NotifierProvider<NetworkDetection, NetworkDetectionState> {
NetworkDetectionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'networkDetectionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$networkDetectionHash();
@$internal
@override
NetworkDetection create() => NetworkDetection();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NetworkDetectionState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NetworkDetectionState>(value),
);
}
}
String _$networkDetectionHash() => r'1cab20d67ec54321b4dbba9d971cd80e98542e23';
abstract class _$NetworkDetection extends $Notifier<NetworkDetectionState> {
NetworkDetectionState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<NetworkDetectionState, NetworkDetectionState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<NetworkDetectionState, NetworkDetectionState>,
NetworkDetectionState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(CurrentSSID)
final currentSSIDProvider = CurrentSSIDProvider._();
File diff suppressed because it is too large Load Diff
+76 -33
View File
@@ -5,29 +5,29 @@ GroupsState currentGroupsState(Ref ref) {
final mode = ref.watch(
patchClashConfigProvider.select((state) => state.mode),
);
final groups = ref.watch(
groupsProvider.select(
(state) => state.map((item) {
return item.copyWith(
now: '',
all: item.all.map((proxy) => proxy.copyWith(now: '')).toList(),
);
}),
final groups = ref.watch(groupsProvider);
final shown = switch (mode) {
Mode.direct => const <Group>[],
Mode.global => groups,
Mode.rule => groups.where(
(item) => item.hidden == false && item.name != GroupName.GLOBAL.name,
),
);
return GroupsState(
value: switch (mode) {
Mode.direct => [],
Mode.global => groups.toList(),
Mode.rule =>
groups
.where((item) => item.hidden == false)
.where((element) => element.name != GroupName.GLOBAL.name)
.toList(),
},
);
};
return GroupsState(value: shown.map(_withoutSelection).toList());
}
Group _withoutSelection(Group group) {
final all = group.all.any(_hasSelection)
? [
for (final proxy in group.all)
_hasSelection(proxy) ? proxy.copyWith(now: '') : proxy,
]
: group.all;
return group.copyWith(now: '', all: all);
}
bool _hasSelection(Proxy proxy) => proxy.now?.isNotEmpty ?? false;
@riverpod
ProxyState proxyState(Ref ref) {
final suspend = ref.watch(suspendProvider);
@@ -67,18 +67,60 @@ ProxiesActionsState proxiesActionsState(Ref ref) {
);
}
/// Watching the delay map instead would drop nodes one probe at a time, and
/// reading it on any other rebuild would drop them whenever something
/// unrelated changed mid-test.
@riverpod
GroupsState filterGroupsState(Ref ref, String query) {
DelayMap delaysAtLastTestBatch(Ref ref) {
ref.watch(sortNumProvider);
ref.listen(delayDataSourceProvider.select((state) => state.isEmpty), (
_,
isEmpty,
) {
if (isEmpty) ref.invalidateSelf();
});
return {
for (final entry in ref.read(delayDataSourceProvider).entries)
entry.key: {...entry.value},
};
}
@riverpod
GroupsState visibleGroupsState(Ref ref) {
final currentGroups = ref.watch(currentGroupsStateProvider);
if (query.isEmpty) {
final hideTimeoutProxies = ref.watch(
proxiesStyleSettingProvider.select((state) => state.hideTimeoutProxies),
);
if (!hideTimeoutProxies) {
return currentGroups;
}
final lowQuery = query.toLowerCase();
return currentGroups.copyWith(
value: computeHideTimeout(
groups: currentGroups.value,
allGroups: ref.watch(groupsProvider),
delayMap: ref.watch(delaysAtLastTestBatchProvider),
selectedMap: ref.watch(selectedMapProvider),
defaultTestUrl: ref.watch(realTestUrlProvider()),
),
);
}
@riverpod
GroupsState filterGroupsState(Ref ref, String query) {
final currentGroups = ref.watch(visibleGroupsStateProvider);
final searchQuery = SearchQuery(query);
if (searchQuery.isEmpty) {
return currentGroups;
}
final matches = <Proxy, bool>{};
final groups = currentGroups.value
.map((group) {
return group.copyWith(
all: group.all
.where((proxy) => proxy.name.toLowerCase().contains(lowQuery))
.where(
(proxy) =>
matches[proxy] ??= searchQuery.matches(proxy.searchFields),
)
.toList(),
);
})
@@ -144,16 +186,15 @@ ProxyGroupSelectorState proxyGroupSelectorState(
) {
final proxiesStyle = ref.watch(proxiesStyleSettingProvider);
final group = ref.watch(
currentGroupsStateProvider.select(
visibleGroupsStateProvider.select(
(state) => state.value.getGroup(groupName),
),
);
final sortNum = ref.watch(sortNumProvider);
final lowQuery = query.toLowerCase();
final proxies =
group?.all.where((item) {
return item.name.toLowerCase().contains(lowQuery);
}).toList() ??
group?.all
.whereMatches(SearchQuery(query), (proxy) => proxy.searchFields)
.toList() ??
[];
return ProxyGroupSelectorState(
testUrl: group?.testUrl,
@@ -185,14 +226,16 @@ int? delay(Ref ref, {required String proxyName, String? testUrl}) {
}
@riverpod
bool delayTestPending(Ref ref, {required String proxyName, String? testUrl}) {
DelayTestPhase? delayTestPhase(
Ref ref, {
required String proxyName,
String? testUrl,
}) {
final currentTestUrl = ref.watch(realTestUrlProvider(testUrl));
final proxyState = ref.watch(realSelectedProxyStateProvider(proxyName));
final effectiveTestUrl = proxyState.testUrl.takeFirstValid([currentTestUrl]);
final key = delayTestKey(effectiveTestUrl, proxyState.proxyName);
return ref.watch(
pendingDelayTestsProvider.select((state) => state.contains(key)),
);
return ref.watch(pendingDelayTestsProvider.select((state) => state[key]));
}
@riverpod
+216 -151
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.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';
@@ -14,196 +14,261 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
class Contributor {
final String avatar;
final String name;
final String link;
const Contributor({
required this.avatar,
required this.name,
required this.link,
});
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 data = await globalState.safeRun<Map<String, dynamic>?>(
final data = await globalState.loadingRun<Map<String, dynamic>?>(
request.checkForUpdate,
title: context.appLocalizations.checkUpdate,
tag: LoadingTag.checkUpdate,
);
unawaited(commonAction.checkUpdateResultHandle(data: data, isUser: true));
}
List<Widget> _buildMoreSection(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
return generateSection(
separated: false,
title: appLocalizations.more,
items: [
ListItem(
title: Text(appLocalizations.checkUpdate),
onTap: () {
_checkUpdate(context, ref);
},
),
ListItem(
title: const Text('Telegram'),
onTap: () {
dialogs.openUrl('https://t.me/FlClash');
},
trailing: const Icon(Icons.launch),
),
ListItem(
title: Text(appLocalizations.project),
onTap: () {
dialogs.openUrl('https://github.com/$repository');
},
trailing: const Icon(Icons.launch),
),
ListItem(
title: Text(appLocalizations.core),
onTap: () {
dialogs.openUrl(
'https://github.com/chen08209/Clash.Meta/tree/FlClash',
);
},
trailing: const Icon(Icons.launch),
),
],
);
}
List<Widget> _buildContributorsSection(AppLocalizations appLocalizations) {
const contributors = [
Contributor(
avatar: 'assets/images/avatar/june2.jpg',
name: 'June2',
link: 'https://t.me/Jibadong',
),
Contributor(
avatar: 'assets/images/avatar/arue.jpg',
name: 'Arue',
link: 'https://t.me/xrcm6868',
),
];
return generateSection(
separated: false,
title: appLocalizations.otherContributors,
items: [
ListItem(
title: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 24,
children: [
for (final contributor in contributors)
Avatar(contributor: contributor),
],
),
),
),
],
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 items = [
ListTile(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Consumer(
builder: (_, ref, _) {
return _DeveloperModeDetector(
child: Wrap(
spacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Image.asset(
'assets/images/icon.png',
width: 64,
height: 64,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appName,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
globalState.packageInfo.version,
style: Theme.of(context).textTheme.labelLarge,
),
],
),
],
),
onEnterDeveloperMode: () {
ref
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(developerMode: true));
context.showNotifier(
appLocalizations.developerModeEnableTip,
level: MessageLevel.success,
);
},
);
},
),
const SizedBox(height: 24),
Text(
appLocalizations.desc,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 12),
..._buildContributorsSection(appLocalizations),
..._buildMoreSection(context, ref),
];
return BaseScaffold(
final isLoading = ref.watch(loadingProvider(LoadingTag.checkUpdate));
return CommonScaffold(
isLoading: isLoading,
title: appLocalizations.about,
body: Padding(
padding: kMaterialListPadding.copyWith(top: 16, bottom: 16),
child: generateListView(items),
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 Avatar extends StatelessWidget {
final Contributor contributor;
class _AboutHero extends StatelessWidget {
final bool isCheckingUpdate;
final VoidCallback onCheckUpdate;
final VoidCallback onEnterDeveloperMode;
const Avatar({super.key, required this.contributor});
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) {
return GestureDetector(
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: [
SizedBox(
width: 36,
height: 36,
child: CircleAvatar(
foregroundImage: AssetImage(contributor.avatar),
_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: 4),
Text(contributor.name, style: context.textTheme.bodySmall),
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;
+75 -31
View File
@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -11,33 +13,48 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ConnectionsView extends ConsumerStatefulWidget {
final ScrollController? scrollController;
final Future<List<TrackerInfo>> Function()? connectionsReader;
const ConnectionsView({super.key, @visibleForTesting this.connectionsReader});
const ConnectionsView({
super.key,
this.scrollController,
@visibleForTesting this.connectionsReader,
});
@override
ConsumerState<ConnectionsView> createState() => _ConnectionsViewState();
}
class _ConnectionsViewState extends ConsumerState<ConnectionsView>
with WidgetsBindingObserver, ActivePollingMixin<ConnectionsView> {
with
WidgetsBindingObserver,
ActivePollingMixin<ConnectionsView>,
RouteMotionHoldMixin<ConnectionsView> {
CoreController get _core => ref.read(coreHandlerProvider);
final _listController = TrackerInfoListController();
final ScrollController _scrollController = ScrollController();
final _speedRanker = TrackerSpeedRanker();
late final ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = widget.scrollController ?? ScrollController();
}
@override
Duration get pollInterval => const Duration(seconds: 1);
List<Widget> _buildActions() {
List<IconButtonData> _buildActions() {
return [
IconButton(
IconButtonData(
glyph: AppGlyphs.clearAll,
tooltip: context.appLocalizations.closeConnections,
onPressed: () async {
unawaited(_core.closeConnections());
await _refreshConnections();
},
icon: const Icon(Icons.delete_sweep_outlined),
),
];
}
@@ -45,15 +62,15 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
@override
Future<void> poll(PollGuard isCurrent) async {
final trackerInfos = await _readConnections();
if (trackerInfos == null || !isCurrent()) {
if (!isCurrent()) {
return;
}
_applyConnections(trackerInfos);
updateWhenRouteSettled(() => _applyConnections(trackerInfos));
}
Future<void> _refreshConnections() async {
final trackerInfos = await _readConnections();
if (trackerInfos == null || !mounted) {
if (!mounted) {
return;
}
_applyConnections(trackerInfos);
@@ -74,21 +91,13 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
}
}
void _applyConnections(List<TrackerInfo> trackerInfos) {
// The core snapshot iterates a Go map, so its order is random per poll;
// sort by total traffic to keep the list stable between refreshes.
final sorted = List.of(trackerInfos)
..sort((a, b) {
final traffic = (b.upload + b.download).compareTo(
a.upload + a.download,
);
if (traffic != 0) {
return traffic;
}
final start = b.start.compareTo(a.start);
return start != 0 ? start : a.id.compareTo(b.id);
});
_listController.setTrackerInfos(sorted);
void _applyConnections(List<TrackerInfo>? trackerInfos) {
if (trackerInfos == null) {
return;
}
_listController.setTrackerInfos(
_speedRanker.rank(trackerInfos, DateTime.now()),
);
}
Future<void> _handleBlockConnection(String id) async {
@@ -99,7 +108,9 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
@override
void dispose() {
_listController.dispose();
_scrollController.dispose();
if (widget.scrollController == null) {
_scrollController.dispose();
}
super.dispose();
}
@@ -107,30 +118,32 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonScaffold(
title: appLocalizations.connections,
title: PageLabel.connections.label,
onKeywordsUpdate: _listController.updateKeywords,
searchState: AppBarSearchState(onSearch: _listController.search),
actions: _buildActions(),
iconActions: _buildActions(),
body: ValueListenableBuilder<TrackerInfosState>(
valueListenable: _listController,
builder: (context, state, _) {
final connections = state.list;
return NullStatusSwitcher(
isEmpty: connections.isEmpty,
isSearching: state.isSearching,
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.connections),
illustration: NullStatusIllustration.connections,
),
child: TrackerInfoAnimatedList(
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
trackerInfos: connections,
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
trailingBuilder: (trackerInfo) => IconButton(
tooltip: appLocalizations.blockConnection,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.block, size: 20),
actionBuilder: (trackerInfo) => _BlockConnectionButton(
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
@@ -142,3 +155,34 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
);
}
}
// One per live row: an IconButton would add a theme animation to each.
class _BlockConnectionButton extends StatelessWidget {
final VoidCallback onPressed;
const _BlockConnectionButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return Tooltip(
message: context.appLocalizations.blockConnection,
child: Semantics(
button: true,
child: InkResponse(
onTap: onPressed,
radius: 14,
child: SizedBox.square(
dimension: 28,
child: Center(
child: GlyphIcon(
AppGlyphs.block,
size: 16,
color: context.colorScheme.onSurfaceVariant,
),
),
),
),
),
);
}
}
+23 -30
View File
@@ -8,20 +8,25 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class RequestsView extends ConsumerStatefulWidget {
const RequestsView({super.key});
final ScrollController? scrollController;
const RequestsView({super.key, this.scrollController});
@override
ConsumerState<RequestsView> createState() => _RequestsViewState();
}
class _RequestsViewState extends ConsumerState<RequestsView> {
class _RequestsViewState extends ConsumerState<RequestsView>
with RouteMotionHoldMixin<RequestsView> {
final _listController = TrackerInfoListController();
late final ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController(initialScrollOffset: double.maxFinite);
_scrollController =
widget.scrollController ??
ScrollController(initialScrollOffset: double.maxFinite);
_listController.setTrackerInfos(ref.read(requestsProvider).list);
ref.listenManual(requestsProvider.select((state) => state.revision), (
_,
@@ -34,7 +39,9 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
@override
void dispose() {
_listController.dispose();
_scrollController.dispose();
if (widget.scrollController == null) {
_scrollController.dispose();
}
super.dispose();
}
@@ -43,46 +50,26 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
if (!mounted) {
return;
}
_listController.setTrackerInfos(ref.read(requestsProvider).list);
}, duration: commonDuration);
updateWhenRouteSettled(
() => _listController.setTrackerInfos(ref.read(requestsProvider).list),
);
}, duration: renderThrottleDuration);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonScaffold(
title: appLocalizations.requests,
title: PageLabel.requests.label,
searchState: AppBarSearchState(onSearch: _listController.search),
onKeywordsUpdate: _listController.updateKeywords,
floatingActionButton: ValueListenableBuilder(
valueListenable: _listController,
builder: (_, state, _) {
final autoScrollToEnd = state.autoScrollToEnd;
return FadeRotationScaleBox(
child: FloatingActionButton(
key: ValueKey(autoScrollToEnd),
onPressed: () {
if (autoScrollToEnd) {
_listController.setAutoScrollToEnd(false);
} else {
_listController.resumeAutoScrollToEnd(
ref.read(requestsProvider).list,
);
}
},
child: autoScrollToEnd
? const Icon(Icons.block)
: const Icon(Icons.vertical_align_top),
),
);
},
),
body: ValueListenableBuilder<TrackerInfosState>(
valueListenable: _listController,
builder: (context, state, _) {
final requests = state.list;
return NullStatusSwitcher(
isEmpty: requests.isEmpty,
isSearching: state.isSearching,
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.requests),
illustration: NullStatusIllustration.requests,
@@ -102,12 +89,18 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(requestsProvider).list,
);
},
child: TrackerInfoList(
reverse: true,
shrinkWrap: true,
physics: const NextClampingScrollPhysics(),
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
trackerInfos: requests,
+13 -20
View File
@@ -16,12 +16,11 @@ class DeveloperView extends ConsumerWidget {
Widget _getDeveloperList(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
return generateSectionV2(
return generateSectionV3(
title: appLocalizations.options,
items: [
ListItem(
title: Text(appLocalizations.messageTest),
minVerticalPadding: 12,
onTap: () {
for (final level in MessageLevel.values) {
context.showNotifier(
@@ -33,7 +32,6 @@ class DeveloperView extends ConsumerWidget {
),
ListItem(
title: Text(appLocalizations.logsTest),
minVerticalPadding: 12,
onTap: () {
for (int i = 0; i < 1000; i++) {
ref
@@ -49,7 +47,6 @@ class DeveloperView extends ConsumerWidget {
if (globalState.canCrashCore)
ListItem(
title: Text(appLocalizations.crashTest),
minVerticalPadding: 12,
onTap: () async {
final coreAction = ref.read(coreActionProvider.notifier);
final res = await dialogs.showMessage(
@@ -63,7 +60,6 @@ class DeveloperView extends ConsumerWidget {
),
ListItem(
title: Text(appLocalizations.clearData),
minVerticalPadding: 12,
onTap: () async {
final storeAction = ref.read(storeActionProvider.notifier);
final res = await dialogs.showMessage(
@@ -77,7 +73,6 @@ class DeveloperView extends ConsumerWidget {
),
ListItem(
title: Text(appLocalizations.pruneCache),
minVerticalPadding: 12,
onTap: () async {
await ref.read(storeActionProvider.notifier).shakingStore();
},
@@ -94,15 +89,14 @@ class DeveloperView extends ConsumerWidget {
);
return BaseScaffold(
title: appLocalizations.developerMode,
body: SingleChildScrollView(
padding: baseInfoEdgeInsets,
child: Column(
children: [
CommonCard(
type: CommonCardType.filled,
radius: AppCorner.md,
child: ListItem.toggle(
padding: const EdgeInsets.only(left: 16, right: 16),
body: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 16),
children: [
generateSectionV3(
items: [
ListItem.toggle(
title: Text(appLocalizations.developerMode),
value: enable,
onChanged: (value) {
@@ -111,11 +105,10 @@ class DeveloperView extends ConsumerWidget {
.update((state) => state.copyWith(developerMode: value));
},
),
),
const SizedBox(height: 16),
_getDeveloperList(context, ref),
],
),
],
),
_getDeveloperList(context, ref),
],
),
);
}
+402
View File
@@ -0,0 +1,402 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
/// Resolves to true only on "Agree"; leaving the page any other way declines.
Future<bool> requestDisclaimerConsent() async {
return await BaseNavigator.push<bool>(
rootNavigatorKey.currentContext!,
const DisclaimerView(requireConsent: true),
) ??
false;
}
class DisclaimerView extends StatelessWidget {
final bool requireConsent;
const DisclaimerView({super.key, this.requireConsent = false});
static const _googlePrivacyUrl = 'https://policies.google.com/privacy';
static const _firebasePrivacyUrl =
'https://firebase.google.com/support/privacy';
static const _maxContentWidth = 720.0;
List<({String title, String content})> _leadingTerms(AppLocalizations l) => [
(title: l.disclaimerSoftwareTitle, content: l.disclaimerSoftwareContent),
(title: l.disclaimerUsageTitle, content: l.disclaimerUsageContent),
(
title: l.disclaimerResponsibilityTitle,
content: l.disclaimerResponsibilityContent,
),
(
title: l.disclaimerThirdPartyTitle,
content: l.disclaimerThirdPartyContent,
),
(title: l.disclaimerWarrantyTitle, content: l.disclaimerWarrantyContent),
(title: l.disclaimerLiabilityTitle, content: l.disclaimerLiabilityContent),
];
List<({String title, String content})> _trailingTerms(AppLocalizations l) => [
(title: l.disclaimerLicenseTitle, content: l.disclaimerLicenseContent),
(title: l.disclaimerChangesTitle, content: l.disclaimerChangesContent),
(title: l.disclaimerAcceptTitle, content: l.disclaimerAcceptContent),
];
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final leadingTerms = _leadingTerms(appLocalizations);
final trailingTerms = _trailingTerms(appLocalizations);
final privacyIndex = leadingTerms.length + 1;
final body = ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 32),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DisclaimerIntro(text: appLocalizations.disclaimerDesc),
for (final (index, term) in leadingTerms.indexed)
_DisclaimerTerm(
index: index + 1,
title: term.title,
content: term.content,
),
_DisclaimerTerm(
index: privacyIndex,
title: appLocalizations.disclaimerPrivacyTitle,
content: appLocalizations.disclaimerPrivacyContent,
children: [
_DataServiceCard(
glyph: AppGlyphs.error,
title: appLocalizations.disclaimerCrashlyticsTitle,
content: appLocalizations.disclaimerCrashlyticsContent,
),
_DataServiceCard(
glyph: AppGlyphs.dataUsage,
title: appLocalizations.disclaimerAnalyticsTitle,
content: appLocalizations.disclaimerAnalyticsContent,
),
_TermParagraphs(
appLocalizations.disclaimerDataProcessingContent,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PolicyLink(
label: appLocalizations.disclaimerGooglePrivacy,
url: _googlePrivacyUrl,
),
_PolicyLink(
label: appLocalizations.disclaimerFirebasePrivacy,
url: _firebasePrivacyUrl,
),
],
),
],
),
for (final (index, term) in trailingTerms.indexed)
_DisclaimerTerm(
index: privacyIndex + index + 1,
title: term.title,
content: term.content,
),
],
),
),
),
],
);
return CommonScaffold(
title: appLocalizations.disclaimer,
body: requireConsent
? Column(
children: [
Expanded(child: body),
const _ConsentBar(maxWidth: _maxContentWidth),
],
)
: body,
);
}
}
class _DisclaimerIntro extends StatelessWidget {
final String text;
const _DisclaimerIntro({required this.text});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: colorScheme.primaryContainer,
shape: AppShape.xl,
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlyphIcon(
AppGlyphs.gavel,
size: 28,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: context.textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer,
height: 1.6,
),
),
),
],
),
),
);
}
}
class _DisclaimerTerm extends StatelessWidget {
final int index;
final String title;
final String content;
final List<Widget> children;
const _DisclaimerTerm({
required this.index,
required this.title,
required this.content,
this.children = const [],
});
static const _badgeSize = 28.0;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
return Padding(
padding: const EdgeInsets.only(top: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
DecoratedBox(
decoration: ShapeDecoration(
color: colorScheme.secondaryContainer,
shape: AppShape.full,
),
child: SizedBox.square(
dimension: _badgeSize,
child: Center(
child: Text(
'$index',
style: textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
_TermParagraphs(content),
for (final child in children)
Padding(padding: const EdgeInsets.only(top: 12), child: child),
],
),
);
}
}
class _TermParagraphs extends StatelessWidget {
final String text;
const _TermParagraphs(this.text);
@override
Widget build(BuildContext context) {
final style = context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
height: 1.6,
);
final paragraphs = text.split('\n\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, paragraph) in paragraphs.indexed)
Padding(
padding: EdgeInsets.only(top: index == 0 ? 0 : 10),
child: Text(paragraph, style: style),
),
],
);
}
}
class _DataServiceCard extends StatelessWidget {
final Glyph glyph;
final String title;
final String content;
const _DataServiceCard({
required this.glyph,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
return DecoratedBox(
decoration: ShapeDecoration(
color: colorScheme.surfaceContainer,
shape: AppShape.xl,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
GlyphIcon(glyph, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
DecoratedBox(
decoration: ShapeDecoration(
color: colorScheme.tertiaryContainer,
shape: AppShape.full,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 2,
),
child: Text(
context.appLocalizations.disclaimerAndroidOnly,
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
const SizedBox(height: 12),
_TermParagraphs(content),
],
),
),
);
}
}
class _PolicyLink extends StatelessWidget {
final String label;
final String url;
const _PolicyLink({required this.label, required this.url});
@override
Widget build(BuildContext context) {
return ElasticButton(
child: TextButton.icon(
onPressed: () {
dialogs.openUrl(url);
},
icon: const GlyphIcon(AppGlyphs.openExternal, size: 18),
label: Text(label),
),
);
}
}
class _ConsentBar extends StatelessWidget {
final double maxWidth;
const _ConsentBar({required this.maxWidth});
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return DecoratedBox(
decoration: BoxDecoration(
color: context.colorScheme.surface,
border: Border(
top: BorderSide(color: context.colorScheme.outlineVariant),
),
),
child: SafeArea(
top: false,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
Expanded(
child: ElasticButton(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(appLocalizations.exit),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElasticButton(
child: FilledButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(appLocalizations.agree),
),
),
),
],
),
),
),
),
),
);
}
}
+90 -65
View File
@@ -2,6 +2,7 @@ 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/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
@@ -48,7 +49,8 @@ class LogsView extends ConsumerStatefulWidget {
ConsumerState<LogsView> createState() => _LogsViewState();
}
class _LogsViewState extends ConsumerState<LogsView> {
class _LogsViewState extends ConsumerState<LogsView>
with RouteMotionHoldMixin<LogsView> {
final _listController = LogListController();
late final ScrollController _scrollController;
@@ -62,14 +64,12 @@ class _LogsViewState extends ConsumerState<LogsView> {
});
}
List<Widget> _buildActions() {
List<IconButtonData> _buildActions() {
return [
IconButton(
IconButtonData(
glyph: AppGlyphs.export,
tooltip: context.appLocalizations.exportLogs,
onPressed: () {
_handleExport();
},
icon: const Icon(Icons.save_as_outlined),
onPressed: _handleExport,
),
];
}
@@ -100,47 +100,27 @@ class _LogsViewState extends ConsumerState<LogsView> {
if (!mounted) {
return;
}
_listController.setLogs(ref.read(logsProvider).list);
}, duration: commonDuration);
updateWhenRouteSettled(
() => _listController.setLogs(ref.read(logsProvider).list),
);
}, duration: renderThrottleDuration);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonScaffold(
actions: _buildActions(),
iconActions: _buildActions(),
onKeywordsUpdate: _listController.updateKeywords,
searchState: AppBarSearchState(onSearch: _listController.search),
title: appLocalizations.logs,
floatingActionButton: ValueListenableBuilder(
valueListenable: _listController,
builder: (_, state, _) {
final autoScrollToEnd = state.autoScrollToEnd;
return FadeRotationScaleBox(
child: FloatingActionButton(
key: ValueKey(autoScrollToEnd),
onPressed: () {
if (autoScrollToEnd) {
_listController.setAutoScrollToEnd(false);
} else {
_listController.resumeAutoScrollToEnd(
ref.read(logsProvider).list,
);
}
},
child: autoScrollToEnd
? const Icon(Icons.block)
: const Icon(Icons.vertical_align_top),
),
);
},
),
body: ValueListenableBuilder<LogsState>(
valueListenable: _listController,
builder: (context, state, _) {
final logs = state.list;
return NullStatusSwitcher(
isEmpty: logs.isEmpty,
isSearching: state.isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.logs,
label: appLocalizations.nullTip(appLocalizations.logs),
@@ -157,6 +137,11 @@ class _LogsViewState extends ConsumerState<LogsView> {
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(logsProvider).list,
);
},
controller: _scrollController,
enable: state.autoScrollToEnd,
dataSource: logs,
@@ -166,6 +151,7 @@ class _LogsViewState extends ConsumerState<LogsView> {
shrinkWrap: true,
controller: _scrollController,
padding: EdgeInsets.only(
top: context.appBarInset,
bottom: 16 + BottomInsetScope.of(context),
),
itemCount: logs.length,
@@ -198,42 +184,81 @@ class LogItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListItem(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
).copyWith(bottom: 12),
final tone = switch (log.logLevel) {
LogLevel.warning => RecordTone.warning,
LogLevel.error => RecordTone.error,
LogLevel.info => RecordTone.neutral,
LogLevel.debug || LogLevel.silent => RecordTone.muted,
};
final payload = LogPayload.parse(log.payload);
return RecordListItem(
tone: tone,
onTap: () {},
minVerticalPadding: 0,
title: SelectableText(
log.payload,
style: context.textTheme.bodyLarge?.copyWith(
color: log.logLevel.color(context),
),
header: RecordHeader(
children: [
RecordTimestamp(log.dateTime),
RecordLabel(
label: log.logLevel.name,
tone: tone,
onPressed: () => onClick?.call(log.logLevel.name),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CommonChip(
label: log.logLevel.name,
onPressed: () => onClick?.call(log.logLevel.name),
),
Flexible(
child: Text(
log.dateTime,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
body: _LogBody(payload: payload, errorColor: tone.accentColor(context)),
);
}
}
class _LogBody extends StatelessWidget {
final LogPayload payload;
final Color? errorColor;
const _LogBody({required this.payload, this.errorColor});
String get _sourceText => [
payload.tag,
if (payload.route case final route?) ...[route.source, route.sourceDetail],
].where((text) => text.isNotEmpty).join(' · ');
@override
Widget build(BuildContext context) {
final styles = RecordTextStyles.of(context);
final route = payload.route;
if (route == null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(payload.message, style: styles.primary),
if (payload.tag.isNotEmpty) Text(payload.tag, style: styles.muted),
],
);
}
return SelectableText.rich(
TextSpan(
children: [
TextSpan(
text: route.destination,
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
),
if (route.error.isNotEmpty)
TextSpan(
text: '\n${route.error}',
style: styles.secondary?.copyWith(color: errorColor),
),
const TextSpan(text: '\n'),
if (route.rule.isNotEmpty) ...[
TextSpan(text: route.rule, style: styles.secondary),
TextSpan(text: ' \u2192 ', style: styles.muted?.toJetBrainsMono),
],
),
TextSpan(
text: route.proxy,
style: styles.secondary?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
TextSpan(text: '\n$_sourceText', style: styles.muted),
],
),
);
}
+29 -22
View File
@@ -1,8 +1,11 @@
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/pages/scan.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -33,26 +36,29 @@ class AddProfileView extends ConsumerWidget {
Future<void> _toAdd(WidgetRef ref) async {
final profilesAction = ref.read(profilesActionProvider.notifier);
final appLocalizations = context.appLocalizations;
final url = await dialogs.showCommonDialog<String>(
child: InputDialog(
autovalidateMode: AutovalidateMode.onUnfocus,
title: appLocalizations.importFromURL,
labelText: appLocalizations.url,
value: '',
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
validator: (value) {
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip('').trim();
}
if (!value.isUrl) {
return appLocalizations.urlTip('').trim();
}
return null;
},
),
final reservedLabels = ref.read(
appProviderLabelsProvider(ProviderKind.proxy),
);
if (url != null) {
unawaited(profilesAction.addProfileFormURL(url));
final res = await dialogs.showNamedUrlInput(
title: appLocalizations.importFromURL,
labelValidator: (value) {
if (reservedLabels.contains(value?.trim())) {
return appLocalizations.existsTip(appLocalizations.name);
}
return null;
},
urlValidator: (value) {
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip('').trim();
}
if (!value.isUrl) {
return appLocalizations.urlTip('').trim();
}
return null;
},
);
if (res != null) {
unawaited(profilesAction.addProfileFormURL(res.url, label: res.label));
}
}
@@ -60,21 +66,22 @@ class AddProfileView extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
return ListView(
padding: EdgeInsets.only(top: context.contentTopPadding, bottom: 16),
children: [
ListItem(
leading: const Icon(Icons.qr_code_sharp),
leading: const GlyphIcon(AppGlyphs.qrCode),
title: Text(appLocalizations.qrcode),
subtitle: Text(appLocalizations.qrcodeDesc),
onTap: () => _toScan(ref),
),
ListItem(
leading: const Icon(Icons.upload_file_sharp),
leading: const GlyphIcon(AppGlyphs.importFile),
title: Text(appLocalizations.file),
subtitle: Text(appLocalizations.fileDesc),
onTap: () => _handleAddProfileFormFile(ref),
),
ListItem(
leading: const Icon(Icons.cloud_download_sharp),
leading: const GlyphIcon(AppGlyphs.cloudDownload),
title: Text(appLocalizations.url),
subtitle: Text(appLocalizations.urlDesc),
onTap: () => _toAdd(ref),
+63 -42
View File
@@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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/models/models.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
@@ -72,10 +73,25 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
),
);
final profilesAction = ref.read(profilesActionProvider.notifier);
final appLocalizations = context.appLocalizations;
final rename = await profilesAction.providerRename(widget.profile, profile);
if (rename.conflicts.isNotEmpty) {
await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(
text: appLocalizations.providerRenameShadowed(
rename.conflicts.map((item) => item.realLabel).join(', '),
profile.realLabel,
),
),
cancelable: false,
);
return;
}
final renameIn = [for (final item in rename.renameIn) item.id];
final hasUpdate = widget.profile.url != profile.url;
if (_fileData != null) {
if (profile.type == ProfileType.url && _autoUpdate) {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(text: appLocalizations.profileHasUpdate),
@@ -94,15 +110,15 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
if (savedProfile == null) {
return;
}
profilesAction.putProfile(savedProfile);
profilesAction.putProfile(savedProfile, renameIn: renameIn);
} else if (!hasUpdate) {
profilesAction.putProfile(profile);
profilesAction.putProfile(profile, renameIn: renameIn);
} else {
unawaited(
globalState.safeRun(() async {
await Future.delayed(commonDuration);
if (hasUpdate) {
await profilesAction.updateProfile(profile);
await profilesAction.updateProfile(profile, renameIn: renameIn);
}
}),
);
@@ -141,22 +157,16 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
}
Future<void> _editProfileFile() async {
if (_rawText == null) {
final profilePath = await appPath.getProfilePath(
widget.profile.id.toString(),
);
final file = File(profilePath);
if (await file.exists()) {
_rawText = await file.readAsString();
}
}
if (!mounted) return;
final title = widget.profile.label.takeFirstValid([
widget.profile.id.toString(),
]);
final editorPage = EditorPage(
title: title,
content: _rawText!,
load: () async => _rawText ??=
await readTextFileTask(
await appPath.getProfilePath(widget.profile.id.toString()),
) ??
'',
onSave: (context, _, content) {
_handleSaveEdit(context, content);
},
@@ -230,7 +240,12 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final items = <Widget>[
_ProfileNameField(controller: _labelController),
_ProfileNameField(
controller: _labelController,
reservedLabels: ref.watch(
appProviderLabelsProvider(ProviderKind.proxy),
),
),
if (widget.profile.type == ProfileType.url) ...[
_ProfileUrlField(controller: _urlController),
ListItem.toggle(
@@ -258,31 +273,30 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
_handleBack();
return false;
},
child: FloatLayout(
floatingWidget: FloatWrapper(
child: CommonFloatingActionButton(
child: CommonScaffold(
title: appLocalizations.edit,
iconActions: [
IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleConfirm,
icon: const Icon(Icons.save),
label: appLocalizations.save,
tooltip: appLocalizations.save,
),
),
child: Form(
],
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Builder(
builder: (context) => ListView.separated(
padding: kMaterialListPadding.copyWith(
bottom: BottomInsetScope.of(context),
),
itemBuilder: (_, index) {
return items[index];
},
separatorBuilder: (_, _) {
return const SizedBox(height: 24);
},
itemCount: items.length,
child: Builder(
builder: (context) => ListView.separated(
padding: kMaterialListPadding.copyWith(
top: context.contentTopPadding,
bottom: BottomInsetScope.of(context) + 16,
),
itemBuilder: (_, index) {
return items[index];
},
separatorBuilder: (_, _) {
return const SizedBox(height: 24);
},
itemCount: items.length,
),
),
),
@@ -294,9 +308,13 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
}
class _ProfileNameField extends StatelessWidget {
const _ProfileNameField({required this.controller});
const _ProfileNameField({
required this.controller,
required this.reservedLabels,
});
final TextEditingController controller;
final Set<String> reservedLabels;
@override
Widget build(BuildContext context) {
@@ -311,6 +329,9 @@ class _ProfileNameField extends StatelessWidget {
if (value == null || value.isEmpty) {
return appLocalizations.profileNameNullValidationDesc;
}
if (reservedLabels.contains(value.trim())) {
return appLocalizations.existsTip(appLocalizations.name);
}
return null;
},
),
@@ -414,12 +435,12 @@ class _ProfileFileItem extends StatelessWidget {
final appLocalizations = context.appLocalizations;
return [
CommonPopupMenuItem(
icon: Icons.edit_outlined,
glyph: AppGlyphs.edit,
label: appLocalizations.edit,
onPressed: onEdit,
),
CommonPopupMenuItem(
icon: Icons.upload_outlined,
glyph: AppGlyphs.upload,
label: appLocalizations.upload,
onPressed: onUpload,
),
@@ -452,7 +473,7 @@ class _ProfileFileItem extends StatelessWidget {
return IconButton(
tooltip: appLocalizations.more,
onPressed: open,
icon: const Icon(Icons.more_vert),
icon: const GlyphIcon(AppGlyphs.more),
);
},
),
+6 -42
View File
@@ -1,56 +1,20 @@
import 'package:fl_clash/common/task.dart';
import 'package:fl_clash/models/profile.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PreviewProfileView extends ConsumerStatefulWidget {
class PreviewProfileView extends ConsumerWidget {
final Profile profile;
const PreviewProfileView({super.key, required this.profile});
@override
ConsumerState<PreviewProfileView> createState() => _PreviewProfileViewState();
}
class _PreviewProfileViewState extends ConsumerState<PreviewProfileView> {
final contentNotifier = ValueNotifier<String?>(null);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final configMap = await ref
.read(setupActionProvider.notifier)
.getProfileWithId(widget.profile.id);
final content = await encodeYamlTask(configMap);
if (!mounted) {
return;
}
contentNotifier.value = content;
});
}
@override
void dispose() {
contentNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: contentNotifier,
builder: (_, content, _) {
final title = widget.profile.realLabel;
return EditorPage(
key: const Key('content'),
title: title,
content: content,
);
},
Widget build(BuildContext context, WidgetRef ref) {
return EditorPage(
title: profile.realLabel,
load: () =>
ref.read(setupActionProvider.notifier).getProfileWithId(profile.id),
);
}
}
+48 -70
View File
@@ -2,6 +2,7 @@ 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/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
@@ -36,7 +37,7 @@ class _ProfilesViewState extends ConsumerState<ProfilesView> {
final context = globalState.navigatorKey.currentState!.context;
showExtend(
context,
builder: (context) => AdaptiveSheetScaffold(
builder: (context) => CommonScaffold(
title: context.appLocalizations.addProfile,
body: AddProfileView(context: context),
),
@@ -71,18 +72,18 @@ class _ProfilesViewState extends ConsumerState<ProfilesView> {
_isUpdating = false;
}
List<Widget> _buildActions(List<Profile> profiles) {
List<IconButtonData> _buildActions(List<Profile> profiles) {
return profiles.isNotEmpty
? [
IconButton(
tooltip: context.appLocalizations.update,
IconButtonData(
glyph: AppGlyphs.sync,
onPressed: () {
_updateProfiles(profiles);
},
icon: const Icon(Icons.sync),
tooltip: context.appLocalizations.update,
),
IconButton(
tooltip: context.appLocalizations.profilesSort,
IconButtonData(
glyph: AppGlyphs.sort,
onPressed: () {
showSheet(
context: context,
@@ -91,21 +92,12 @@ class _ProfilesViewState extends ConsumerState<ProfilesView> {
},
);
},
icon: const Icon(Icons.sort),
iconSize: 26,
tooltip: context.appLocalizations.profilesSort,
),
]
: [];
}
Widget _buildFAB() {
return CommonFloatingActionButton(
onPressed: _handleShowAddExtendPage,
icon: const Icon(Icons.add),
label: context.appLocalizations.addProfile,
);
}
@override
Widget build(BuildContext context) {
return Consumer(
@@ -117,13 +109,28 @@ class _ProfilesViewState extends ConsumerState<ProfilesView> {
return CommonScaffold(
isLoading: isLoading,
title: appLocalizations.profiles,
floatingActionButton: _buildFAB(),
actions: _buildActions(state.profiles),
primaryAction: state.profiles.isEmpty
? null
: IconButtonData(
glyph: AppGlyphs.add,
onPressed: _handleShowAddExtendPage,
tooltip: appLocalizations.addProfile,
),
iconActions: _buildActions(state.profiles),
foldPrimaryAction: true,
body: NullStatusSwitcher(
isEmpty: state.profiles.isEmpty,
nullStatus: NullStatus(
label: appLocalizations.nullProfileDesc,
label: appLocalizations.nullTip(appLocalizations.profiles),
description: appLocalizations.nullProfileDesc,
illustration: NullStatusIllustration.profile,
action: ElasticButton(
child: FilledButton.tonalIcon(
onPressed: _handleShowAddExtendPage,
icon: const GlyphIcon(AppGlyphs.add, fill: 1),
label: Text(appLocalizations.addProfile),
),
),
),
child: _ProfilesGrid(
profiles: state.profiles,
@@ -164,7 +171,7 @@ class _ProfilesGrid extends ConsumerWidget {
padding: EdgeInsets.only(
left: _horizontalPadding,
right: _horizontalPadding,
top: 16,
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
crossAxisCount: columns,
@@ -260,10 +267,7 @@ class ProfileItem extends ConsumerWidget {
void _handleShowEditExtendPage(BuildContext context) {
showExtend(
context,
builder: (context) => AdaptiveSheetScaffold(
title: context.appLocalizations.edit,
body: EditProfileView(profile: profile, context: context),
),
builder: (context) => EditProfileView(profile: profile, context: context),
);
}
@@ -331,14 +335,14 @@ class ProfileItem extends ConsumerWidget {
isUrl && subscriptionInfo != null && subscriptionInfo.total > 0;
return [
CommonPopupMenuItem(
icon: Icons.edit_outlined,
glyph: AppGlyphs.edit,
label: appLocalizations.edit,
onPressed: () {
_handleShowEditExtendPage(context);
},
),
CommonPopupMenuItem(
icon: Icons.visibility_outlined,
glyph: AppGlyphs.eye,
label: appLocalizations.preview,
onPressed: () {
_handlePreview(context);
@@ -346,18 +350,18 @@ class ProfileItem extends ConsumerWidget {
),
if (isUrl)
CommonPopupMenuItem(
icon: Icons.sync_alt_sharp,
glyph: AppGlyphs.sync,
label: appLocalizations.sync,
onPressed: () {
updateProfile(ref);
},
),
CommonPopupMenuItem(
icon: Icons.emergency_outlined,
glyph: AppGlyphs.moreCircle,
label: appLocalizations.more,
subItems: [
CommonPopupMenuItem(
icon: Icons.extension_outlined,
glyph: AppGlyphs.puzzle,
label: appLocalizations.override,
onPressed: () {
_handlePushGenProfilePage(context, profile.id);
@@ -365,7 +369,7 @@ class ProfileItem extends ConsumerWidget {
),
if (hasSubscriptionInfo)
CommonPopupMenuItem(
icon: Icons.data_usage,
glyph: AppGlyphs.dataUsage,
label: appLocalizations.subscriptionInfo,
onPressed: () {
_handleShowSubscriptionInfo(context);
@@ -373,14 +377,14 @@ class ProfileItem extends ConsumerWidget {
),
if (isUrl)
CommonPopupMenuItem(
icon: Icons.copy,
glyph: AppGlyphs.copy,
label: appLocalizations.copyLink,
onPressed: () {
_handleCopyLink(context);
},
),
CommonPopupMenuItem(
icon: Icons.file_copy_outlined,
glyph: AppGlyphs.export,
label: appLocalizations.exportFile,
onPressed: () {
_handleExportFile(context);
@@ -390,7 +394,7 @@ class ProfileItem extends ConsumerWidget {
),
CommonPopupMenuItem(
danger: true,
icon: Icons.delete_outlined,
glyph: AppGlyphs.delete,
label: appLocalizations.delete,
onPressed: () {
_handleDeleteProfile(context, ref);
@@ -443,7 +447,7 @@ class ProfileItem extends ConsumerWidget {
onPressed: () {
open();
},
icon: const Icon(Icons.more_vert),
icon: const GlyphIcon(AppGlyphs.more),
);
},
),
@@ -489,33 +493,6 @@ class _ProfileCardTitle extends StatelessWidget {
}
}
class LastUpdateTimeText extends StatelessWidget {
final DateTime? lastUpdateDate;
final TextStyle? style;
const LastUpdateTimeText({
super.key,
required this.lastUpdateDate,
this.style,
});
@override
Widget build(BuildContext context) {
if (lastUpdateDate == null) {
return Text('', style: style);
}
return TickBuilder(
duration: const Duration(minutes: 1),
builder: (context, _) {
return Text(
lastUpdateDate!.getLastUpdateTimeDesc(context),
style: style,
);
},
);
}
}
class ReorderableProfilesSheet extends ConsumerStatefulWidget {
final List<Profile> profiles;
@@ -545,7 +522,7 @@ class _ReorderableProfilesSheetState
child: ReorderableDelayedDragStartListener(
index: index,
child: DecorationListItem(
trailing: const Icon(Icons.drag_handle),
trailing: const GlyphIcon(AppGlyphs.dragHandle),
title: Text(profile.realLabel),
),
),
@@ -560,13 +537,14 @@ class _ReorderableProfilesSheetState
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return AdaptiveSheetScaffold(
sheetTransparentToolBar: true,
return CommonScaffold(
actions: [
IconButtonData(
icon: Icons.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
),
),
],
body: Padding(
@@ -575,7 +553,7 @@ class _ReorderableProfilesSheetState
buildDefaultDragHandles: false,
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.sheetTopPadding),
).copyWith(top: context.contentTopPadding),
proxyDecorator: (child, index, animation) {
return commonProxyDecorator(_buildItem(index), index, animation);
},
+182 -72
View File
@@ -1,5 +1,6 @@
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/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
@@ -7,6 +8,30 @@ 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;
@@ -25,56 +50,6 @@ class ProxyCard extends ConsumerWidget {
Measure get measure => globalState.measure;
void _handleTestCurrentDelay(WidgetRef ref) {
ref.read(proxiesActionProvider.notifier).proxyDelayTest(proxy, testUrl);
}
Widget _buildDelayText() {
return SizedBox(
height: measure.labelSmallHeight,
child: Consumer(
builder: (context, ref, _) {
final delay = ref.watch(
delayProvider(proxyName: proxy.name, testUrl: testUrl),
);
final pending = ref.watch(
delayTestPendingProvider(proxyName: proxy.name, testUrl: testUrl),
);
return FadeBox(
alignment: type == ProxyCardType.expand
? Alignment.centerLeft
: Alignment.centerRight,
child: pending || delay == null
? SizedBox(
height: measure.labelSmallHeight,
width: measure.labelSmallHeight,
child: pending
? const CommonCircleLoading()
: IconButton(
tooltip: context.appLocalizations.delayTest,
icon: const Icon(Icons.bolt),
iconSize: globalState.measure.labelSmallHeight,
padding: EdgeInsets.zero,
onPressed: () => _handleTestCurrentDelay(ref),
),
)
: GestureDetector(
onTap: () => _handleTestCurrentDelay(ref),
child: Text(
delay > 0 ? '$delay ms' : 'Timeout',
maxLines: 1,
style: context.textTheme.labelSmall?.copyWith(
overflow: TextOverflow.ellipsis,
color: getDelayColor(delay),
),
),
),
);
},
),
);
}
Widget _buildProxyNameText(BuildContext context) {
if (type == ProxyCardType.min) {
return SizedBox(
@@ -99,30 +74,10 @@ class ProxyCard extends ConsumerWidget {
}
}
Future<void> _changeProxy(WidgetRef ref) async {
final isComputedSelected = groupType.isComputedSelected;
final isSelector = groupType == GroupType.Selector;
if (isComputedSelected || isSelector) {
final currentProxyName = ref.read(proxyNameProvider(groupName));
final nextProxyName = switch (isComputedSelected) {
true => currentProxyName == proxy.name ? '' : proxy.name,
false => proxy.name,
};
ref
.read(proxiesActionProvider.notifier)
.changeProxyDebounce(groupName, nextProxyName);
return;
}
dialogs.showNotifier(
currentAppLocalizations.notSelectedTip,
level: MessageLevel.warning,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final measure = globalState.measure;
final delayText = _buildDelayText();
final delayText = _DelayText(proxy: proxy, testUrl: testUrl, type: type);
final proxyNameText = _buildProxyNameText(context);
return Stack(
children: [
@@ -133,9 +88,15 @@ class ProxyCard extends ConsumerWidget {
);
return CommonCard(
radius: AppCorner.lg,
enterActionsOnRight: true,
key: key,
onPressed: () {
_changeProxy(ref);
selectGroupProxy(
ref,
groupName: groupName,
groupType: groupType,
proxyName: proxy.name,
);
},
isSelected: selectedProxyName == proxy.name,
child: child!,
@@ -200,6 +161,155 @@ class ProxyCard extends ConsumerWidget {
}
}
class _DelayText extends ConsumerStatefulWidget {
const _DelayText({
required this.proxy,
required this.testUrl,
required this.type,
});
final Proxy proxy;
final String? testUrl;
final ProxyCardType type;
@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: RoundedSuperellipseBorder(
borderRadius: AppRadius.xs,
side: BorderSide(
color: context.colorScheme.primary,
width: 2,
),
),
),
),
),
),
],
);
}
@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.labelSmallHeight,
child: FadeBox(
alignment: widget.type == ProxyCardType.expand
? Alignment.centerLeft
: Alignment.centerRight,
child: phase != null || delay == null
? SizedBox(
height: measure.labelSmallHeight,
width: measure.labelSmallHeight,
child: _withFocusRing(context, switch (phase) {
DelayTestPhase.running => const CommonCircleLoading(),
DelayTestPhase.queued => GlyphIcon(
AppGlyphs.clock,
size: measure.labelSmallHeight,
color: context.colorScheme.onSurfaceVariant.opacity38,
),
null => IconButton(
tooltip: context.appLocalizations.delayTest,
icon: const GlyphIcon(AppGlyphs.bolt),
iconSize: measure.labelSmallHeight,
padding: EdgeInsets.zero,
onPressed: _handleTestCurrentDelay,
),
}),
)
: GestureDetector(
onTap: _handleTestCurrentDelay,
child: _withFocusRing(
context,
Text(
delay > 0 ? '$delay ms' : 'Timeout',
maxLines: 1,
style: context.textTheme.labelSmall?.copyWith(
overflow: TextOverflow.ellipsis,
color: context.colorScheme.delayColor(delay),
),
),
),
),
),
),
),
);
}
}
class _ProxyDesc extends ConsumerWidget {
final Proxy proxy;
+27 -16
View File
@@ -1,12 +1,13 @@
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:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/widgets.dart';
const proxyGridSpacing = 8.0;
double get listHeaderHeight {
final measure = globalState.measure;
return 20 + measure.titleMediumHeight + 4 + measure.bodyMediumHeight + 2;
return 20 + measure.titleSmallHeight + 2 + measure.labelSmallHeight + 2;
}
double getItemHeight(ProxyCardType proxyCardType) {
@@ -20,6 +21,9 @@ double getItemHeight(ProxyCardType proxyCardType) {
};
}
double getRowExtent(ProxyCardType proxyCardType) =>
getItemHeight(proxyCardType) + proxyGridSpacing;
class GroupOffsets {
const GroupOffsets(this.groups, this.offsets);
@@ -41,20 +45,27 @@ class GroupOffsets {
Group? groupOf(String groupName) => groups.getGroup(groupName);
}
double getScrollToSelectedOffset({
required WidgetRef ref,
required String groupName,
double? selectedRowOffset({
required List<Proxy> proxies,
required String? selectedProxyName,
required int columns,
required double rowExtent,
}) {
final proxyCardType = ref.read(
proxiesStyleSettingProvider.select((state) => state.cardType),
);
final selectedProxyName = ref.read(selectedProxyNameProvider(groupName));
final findSelectedIndex = proxies.indexWhere(
(proxy) => proxy.name == selectedProxyName,
);
final selectedIndex = findSelectedIndex != -1 ? findSelectedIndex : 0;
final rows = (selectedIndex / columns).floor();
return rows * getItemHeight(proxyCardType) + (rows - 1) * 8;
final index = proxies.indexWhere((proxy) => proxy.name == selectedProxyName);
if (index < 0) {
return null;
}
return (index ~/ columns) * rowExtent;
}
void animateScrollTo(ScrollController controller, double offset) {
if (!controller.hasClients) {
return;
}
final position = controller.position;
controller.animateTo(
offset.clamp(position.minScrollExtent, position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
+140 -161
View File
@@ -3,6 +3,7 @@ import 'dart:math';
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/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -14,6 +15,8 @@ import 'common.dart';
typedef GroupNameProxiesMap = Map<String, List<Proxy>>;
const _pinnedHeaderGap = 8.0;
const _headerInset = 10.0;
const _enterStaggerLimit = 8;
const _enterStaggerStep = Duration(milliseconds: 20);
const _enterSlideBase = 32.0;
@@ -75,11 +78,11 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
required ProxyCardType cardType,
}) {
final offsets = <double>[];
final rowExtent = getItemHeight(cardType) + 8;
final rowExtent = getRowExtent(cardType);
var currentOffset = 0.0;
for (final group in groups) {
offsets.add(currentOffset);
currentOffset += listHeaderHeight + 8;
currentOffset += listHeaderHeight + proxyGridSpacing;
if (currentUnfoldSet.contains(group.name)) {
final rowCount = (group.all.length + columns - 1) ~/ columns;
currentOffset += rowCount * rowExtent;
@@ -129,7 +132,7 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
.fill(columns, filler: (_) => const Flexible(child: SizedBox()))
.separated(const SizedBox(width: 8));
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
padding: const EdgeInsets.only(left: 18, right: 18, bottom: 8),
child: Row(children: children.toList()),
);
}
@@ -149,31 +152,28 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
return SliverMainAxisGroup(
slivers: [
PinnedHeaderSliver(
child: ColoredBox(
color: context.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: SizedBox(
height: listHeaderHeight,
child: ListHeader(
enterAnimated: false,
onScrollToSelected: (groupName) {
_scrollToGroupSelected(groupName, columns);
},
key: ValueKey(groupName),
isExpand: isExpand,
group: group,
onChange: (groupName) {
_handleChange(currentUnfoldSet, groupName);
},
),
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: SizedBox(
height: listHeaderHeight,
child: ListHeader(
enterAnimated: false,
onScrollToSelected: (groupName) {
_scrollToGroupSelected(groupName, columns, cardType);
},
key: ValueKey(groupName),
isExpand: isExpand,
group: group,
onChange: (groupName) {
_handleChange(currentUnfoldSet, groupName);
},
),
),
),
),
if (isExpand)
SliverFixedExtentList(
itemExtent: getItemHeight(cardType) + 8,
itemExtent: getRowExtent(cardType),
delegate: SliverChildBuilderDelegate(
(_, index) => _buildProxyRow(
group: group,
@@ -248,32 +248,25 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
);
}
void _scrollToGroupSelected(String groupName, int columns) {
final currentInitOffset = _getGroupOffset(groupName);
void _scrollToGroupSelected(
String groupName,
int columns,
ProxyCardType cardType,
) {
final proxies = _groupOffsets.groupOf(groupName)?.all;
_jumpTo(
currentInitOffset +
8 +
getScrollToSelectedOffset(
ref: ref,
groupName: groupName,
proxies: proxies ?? [],
columns: columns,
),
);
}
void _jumpTo(double offset) {
if (mounted && _controller.hasClients) {
_controller.animateTo(
offset.clamp(
_controller.position.minScrollExtent,
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
if (proxies == null) {
return;
}
final rowOffset = selectedRowOffset(
proxies: proxies,
selectedProxyName: ref.read(selectedProxyNameProvider(groupName)),
columns: columns,
rowExtent: getRowExtent(cardType),
);
if (rowOffset == null) {
return;
}
animateScrollTo(_controller, _getGroupOffset(groupName) + rowOffset);
}
@override
@@ -286,8 +279,14 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
final proxiesLayout = ref.watch(
proxiesStyleSettingProvider.select((state) => state.layout),
);
final isSearching = ref.watch(
queryProvider(
QueryTag.proxies,
).select((query) => SearchQuery(query).isNotEmpty),
);
return NullStatusSwitcher(
isEmpty: state.groups.isEmpty,
isSearching: isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.proxies,
label: appLocalizations.nullTip(appLocalizations.proxies),
@@ -295,7 +294,7 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
child: LayoutBuilder(
builder: (_, constraints) {
final columns = getProxiesColumns(
max(constraints.maxWidth - 32, 0),
max(constraints.maxWidth - 36, 0),
proxiesLayout,
);
_groupOffsets = _getGroupOffsets(
@@ -304,35 +303,35 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
columns: columns,
cardType: state.proxyCardType,
);
containerHeight = max(constraints.maxHeight - 16, 0);
final barInset = MediaQuery.paddingOf(context).top;
containerHeight = max(
constraints.maxHeight - barInset - _pinnedHeaderGap,
0,
);
return CommonScrollBar(
controller: _controller,
thumbVisibility: true,
trackVisibility: true,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: ScrollConfiguration(
behavior: const HiddenBarScrollBehavior(),
child: CustomScrollView(
key: proxiesListStoreKey,
controller: _controller,
slivers: [
for (final group in state.groups)
_buildGroup(
context,
group: group,
currentUnfoldSet: state.currentUnfoldSet,
columns: columns,
cardType: state.proxyCardType,
),
SliverToBoxAdapter(
child: SizedBox(
height: 16 + BottomInsetScope.of(context),
),
),
],
child: CustomScrollView(
key: proxiesListStoreKey,
controller: _controller,
slivers: [
PinnedHeaderSliver(
child: SizedBox(height: barInset + _pinnedHeaderGap),
),
),
for (final group in state.groups)
_buildGroup(
context,
group: group,
currentUnfoldSet: state.currentUnfoldSet,
columns: columns,
cardType: state.proxyCardType,
),
SliverToBoxAdapter(
child: SizedBox(
height: 16 + BottomInsetScope.of(context),
),
),
],
),
);
},
@@ -343,7 +342,7 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
}
}
class ListHeader extends ConsumerStatefulWidget {
class ListHeader extends ConsumerWidget {
final Group group;
final Function(String groupName) onChange;
@@ -362,73 +361,51 @@ class ListHeader extends ConsumerStatefulWidget {
});
@override
ConsumerState<ListHeader> createState() => _ListHeaderState();
}
class _ListHeaderState extends ConsumerState<ListHeader> {
var isLock = false;
String get icon => widget.group.icon;
String get groupName => widget.group.name;
String get groupType => widget.group.type.name;
bool get isExpand => widget.isExpand;
Future<void> _delayTest() async {
if (isLock) return;
isLock = true;
try {
await ref
.read(proxiesActionProvider.notifier)
.delayTest(widget.group.all, widget.group.testUrl);
} finally {
isLock = false;
}
}
void _handleChange(String groupName) {
widget.onChange(groupName);
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final groupName = group.name;
final isDelayTesting = ref.watch(
delayTestingGroupsProvider.select((state) => state.contains(groupName)),
);
return CommonCard(
enterActionsOnRight: true,
enterAnimated: widget.enterAnimated,
key: widget.key,
enterAnimated: enterAnimated,
key: key,
radius: AppCorner.xl.ap,
type: CommonCardType.filled,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.all(_headerInset),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
children: [
_GroupIcon(src: icon),
_GroupIcon(src: group.icon),
Flexible(child: _GroupSummary(groupName: groupName)),
],
),
),
_GroupActions(
isExpand: isExpand,
groupType: groupType,
isDelayTesting: isDelayTesting,
groupType: group.type.name,
onScrollToSelected: () {
widget.onScrollToSelected(groupName);
onScrollToSelected(groupName);
},
onDelayTest: () {
ref
.read(proxiesActionProvider.notifier)
.delayTestPageGroup(groupName);
},
onDelayTest: _delayTest,
onToggle: () {
_handleChange(groupName);
onChange(groupName);
},
),
],
),
),
onPressed: () {
_handleChange(groupName);
onChange(groupName);
},
);
}
@@ -445,7 +422,7 @@ class _GroupIcon extends ConsumerWidget {
proxiesStyleSettingProvider.select((state) => state.iconStyle),
);
return switch (iconStyle) {
ProxiesIconStyle.standard => LayoutBuilder(
ProxiesIconStyle.filled => LayoutBuilder(
builder: (_, constraints) {
return Container(
margin: const EdgeInsets.only(right: 12),
@@ -458,7 +435,7 @@ class _GroupIcon extends ConsumerWidget {
padding: EdgeInsets.all(6.ap),
decoration: ShapeDecoration(
color: context.colorScheme.secondaryContainer,
shape: AppShape.md,
shape: AppShape.all(AppCorner.xl.ap - _headerInset),
),
clipBehavior: Clip.antiAlias,
child: IconTheme.merge(
@@ -470,7 +447,7 @@ class _GroupIcon extends ConsumerWidget {
);
},
),
ProxiesIconStyle.icon => Container(
ProxiesIconStyle.plain => Container(
margin: const EdgeInsets.only(right: 8),
child: LayoutBuilder(
builder: (_, constraints) {
@@ -481,7 +458,7 @@ class _GroupIcon extends ConsumerWidget {
},
),
),
ProxiesIconStyle.none => Container(),
ProxiesIconStyle.hidden => Container(),
};
}
}
@@ -497,8 +474,8 @@ class _GroupSummary extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EmojiText(groupName, style: context.textTheme.titleMedium),
const SizedBox(height: 4),
EmojiText(groupName, style: context.textTheme.titleSmall),
const SizedBox(height: 2),
Flexible(flex: 1, child: _SelectedProxyName(groupName: groupName)),
],
);
@@ -530,17 +507,15 @@ class _SelectedProxyName extends ConsumerWidget {
class _GroupActions extends StatelessWidget {
const _GroupActions({
required this.isExpand,
required this.isDelayTesting,
required this.groupType,
required this.onScrollToSelected,
required this.onDelayTest,
required this.onToggle,
});
static const _shrinkWrap = ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
final bool isExpand;
final bool isDelayTesting;
final String groupType;
final VoidCallback onScrollToSelected;
final VoidCallback onDelayTest;
@@ -548,45 +523,49 @@ class _GroupActions extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
children: [
if (isExpand) ...[
IconButton(
tooltip: context.appLocalizations.scrollToSelected,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(2),
onPressed: onScrollToSelected,
style: _shrinkWrap,
iconSize: 19,
icon: const Icon(Icons.adjust),
),
const SizedBox(width: 2),
IconButton(
tooltip: context.appLocalizations.delayTest,
iconSize: 20,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(2),
onPressed: onDelayTest,
style: _shrinkWrap,
icon: const Icon(Icons.network_ping),
),
const SizedBox(width: 6),
] else ...[
Text(groupType, style: context.textTheme.labelMedium?.toLight),
return TonalButtonTheme(
size: TonalButtonSize.compact,
child: Row(
children: [
if (isExpand)
TonalButtonGroup(
size: TonalButtonSize.compact,
children: [
IconButton(
tooltip: context.appLocalizations.scrollToSelected,
onPressed: onScrollToSelected,
iconSize: 19,
icon: const GlyphIcon(AppGlyphs.locate),
),
IconButton(
tooltip: context.appLocalizations.delayTest,
onPressed: isDelayTesting ? null : onDelayTest,
icon: isDelayTesting
? SizedBox.square(
dimension: TonalButtonSize.compact.icon,
child: const Padding(
padding: EdgeInsets.all(2),
child: CommonCircleLoading(),
),
)
: const GlyphIcon(AppGlyphs.bolt),
),
],
)
else
Text(groupType, style: context.textTheme.labelMedium?.toLight),
const SizedBox(width: 6),
ElasticPress(
child: IconButton(
tooltip: isExpand
? context.appLocalizations.showLess
: context.appLocalizations.showMore,
onPressed: onToggle,
icon: CommonExpandIcon(expand: isExpand),
),
),
],
IconButton.filledTonal(
tooltip: isExpand
? context.appLocalizations.showLess
: context.appLocalizations.showMore,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(2),
iconSize: 24,
style: _shrinkWrap,
onPressed: onToggle,
icon: CommonExpandIcon(expand: isExpand),
),
],
),
);
}
}
+62 -61
View File
@@ -1,6 +1,7 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/state.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/views/proxies/list.dart';
import 'package:fl_clash/views/proxies/providers.dart';
@@ -23,73 +24,63 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
bool _hasProviders = false;
bool _isTab = false;
List<Widget> _buildActions(BuildContext context) {
List<CommonPopupMenuItem> _buildMenuItems(BuildContext context) {
final appLocalizations = context.appLocalizations;
return [
if (_isTab)
IconButton(
tooltip: context.appLocalizations.scrollToSelected,
onPressed: () {
_proxiesTabKey.currentState?.scrollToGroupSelected();
},
icon: const Icon(Icons.adjust, weight: 1),
),
CommonPopupBox(
targetBuilder: (open) {
return IconButton(
tooltip: context.appLocalizations.more,
onPressed: () {
final isMobile = ref.read(isMobileViewProvider);
open(offset: Offset(0, isMobile ? 0 : 20));
CommonPopupMenuItem(
glyph: AppGlyphs.sliders,
label: appLocalizations.settings,
onPressed: () {
showSheet(
context: context,
props: const SheetProps(isScrollControlled: true),
builder: (_) {
return CommonScaffold(
body: const ProxiesSetting(),
title: appLocalizations.settings,
);
},
icon: const Icon(Icons.more_vert),
);
},
popupBuilder: (_) => CommonPopupMenu(
items: [
CommonPopupMenuItem(
icon: Icons.tune,
label: appLocalizations.settings,
onPressed: () {
showSheet(
context: context,
props: const SheetProps(isScrollControlled: true),
builder: (_) {
return AdaptiveSheetScaffold(
body: const ProxiesSetting(),
title: appLocalizations.settings,
);
},
);
},
),
if (_hasProviders)
CommonPopupMenuItem(
icon: Icons.poll_outlined,
label: appLocalizations.providers,
onPressed: () {
showExtend(
context,
builder: (_) {
return const ProvidersView();
},
);
},
),
],
),
),
if (_hasProviders)
CommonPopupMenuItem(
glyph: AppGlyphs.layers,
label: appLocalizations.providers,
onPressed: () {
showExtend(
context,
builder: (_) {
return const ProvidersView();
},
);
},
),
];
}
Widget? _buildFAB() {
return _isTab
? DelayTestButton(
onClick: () async {
await _proxiesTabKey.currentState?.delayTestCurrentGroup();
},
)
: null;
IconButtonData? _buildPrimaryAction() {
if (!_isTab) {
return null;
}
final currentGroupName = ref.watch(
proxiesTabControllerStateProvider.select(
(state) => state.currentGroupName,
),
);
final isDelayTesting = ref.watch(
delayTestingGroupsProvider.select(
(state) => state.contains(currentGroupName),
),
);
return IconButtonData(
glyph: AppGlyphs.bolt,
onPressed: () {
_proxiesTabKey.currentState?.delayTestCurrentGroup();
},
tooltip: context.appLocalizations.delayTest,
isLoading: isDelayTesting,
);
}
void _onSearch(String value) {
@@ -133,8 +124,18 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
return CommonScaffold(
isLoading: isLoading,
resizeToAvoidBottomInset: false,
floatingActionButton: _buildFAB(),
actions: _buildActions(context),
primaryAction: _buildPrimaryAction(),
iconActions: [
if (_isTab)
IconButtonData(
glyph: AppGlyphs.locate,
onPressed: () {
_proxiesTabKey.currentState?.scrollToGroupSelected();
},
tooltip: context.appLocalizations.scrollToSelected,
),
],
menuItems: _buildMenuItems(context),
title: context.appLocalizations.proxies,
searchState: AppBarSearchState(onSearch: _onSearch),
body: switch (proxiesType) {
+108 -49
View File
@@ -1,25 +1,26 @@
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/config.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProxiesSetting extends StatelessWidget {
class ProxiesSetting extends ConsumerWidget {
const ProxiesSetting({super.key});
IconData _getIconWithProxiesType(ProxiesType type) {
Glyph _getIconWithProxiesType(ProxiesType type) {
return switch (type) {
ProxiesType.tab => Icons.view_carousel,
ProxiesType.list => Icons.view_list,
ProxiesType.tab => AppGlyphs.layoutTabs,
ProxiesType.list => AppGlyphs.layoutList,
};
}
IconData _getIconWithProxiesSortType(ProxiesSortType type) {
Glyph _getIconWithProxiesSortType(ProxiesSortType type) {
return switch (type) {
ProxiesSortType.none => Icons.sort,
ProxiesSortType.delay => Icons.network_ping,
ProxiesSortType.name => Icons.sort_by_alpha,
ProxiesSortType.none => AppGlyphs.sort,
ProxiesSortType.delay => AppGlyphs.bolt,
ProxiesSortType.name => AppGlyphs.sortAlpha,
};
}
@@ -50,9 +51,33 @@ class ProxiesSetting extends StatelessWidget {
) {
final appLocalizations = context.appLocalizations;
return switch (style) {
ProxiesIconStyle.standard => appLocalizations.standard,
ProxiesIconStyle.none => appLocalizations.none,
ProxiesIconStyle.icon => appLocalizations.onlyIcon,
ProxiesIconStyle.filled => appLocalizations.iconStyleFilled,
ProxiesIconStyle.plain => appLocalizations.iconStylePlain,
ProxiesIconStyle.hidden => appLocalizations.iconStyleHidden,
};
}
Glyph _getIconWithProxiesLayout(ProxiesLayout proxiesLayout) {
return switch (proxiesLayout) {
ProxiesLayout.tight => AppGlyphs.columnsThree,
ProxiesLayout.standard => AppGlyphs.columnsTwo,
ProxiesLayout.loose => AppGlyphs.columnsOne,
};
}
Glyph _getIconWithProxyCardType(ProxyCardType type) {
return switch (type) {
ProxyCardType.expand => AppGlyphs.cardLarge,
ProxyCardType.shrink => AppGlyphs.cardMedium,
ProxyCardType.min => AppGlyphs.cardSmall,
};
}
Glyph _getIconWithProxiesIconStyle(ProxiesIconStyle style) {
return switch (style) {
ProxiesIconStyle.filled => AppGlyphs.iconTile,
ProxiesIconStyle.plain => AppGlyphs.iconPlain,
ProxiesIconStyle.hidden => AppGlyphs.eyeOff,
};
}
@@ -77,7 +102,7 @@ class ProxiesSetting extends StatelessWidget {
SettingInfoCard(
Info(
label: item.label,
iconData: _getIconWithProxiesType(item),
glyph: _getIconWithProxiesType(item),
),
isSelected: proxiesType == item,
onPressed: () {
@@ -117,7 +142,7 @@ class ProxiesSetting extends StatelessWidget {
SettingInfoCard(
Info(
label: _getStringProxiesSortType(context, item),
iconData: _getIconWithProxiesSortType(item),
glyph: _getIconWithProxiesSortType(item),
),
isSelected: sortType == item,
onPressed: () {
@@ -154,8 +179,11 @@ class ProxiesSetting extends StatelessWidget {
spacing: 16,
children: [
for (final item in ProxyCardType.values)
SettingTextCard(
item.label,
SettingInfoCard(
Info(
label: item.label,
glyph: _getIconWithProxyCardType(item),
),
isSelected: item == cardType,
onPressed: () {
ref.read(proxiesStyleSettingProvider.notifier).update((
@@ -191,8 +219,11 @@ class ProxiesSetting extends StatelessWidget {
spacing: 16,
children: [
for (final item in ProxiesLayout.values)
SettingTextCard(
getTextForProxiesLayout(context, item),
SettingInfoCard(
Info(
label: getTextForProxiesLayout(context, item),
glyph: _getIconWithProxiesLayout(item),
),
isSelected: item == layout,
onPressed: () {
ref.watch(proxiesStyleSettingProvider.notifier).update((
@@ -228,8 +259,11 @@ class ProxiesSetting extends StatelessWidget {
spacing: 16,
children: [
for (final item in ProxiesIconStyle.values)
SettingTextCard(
_getTextWithProxiesIconStyle(context, item),
SettingInfoCard(
Info(
label: _getTextWithProxiesIconStyle(context, item),
glyph: _getIconWithProxiesIconStyle(item),
),
isSelected: iconStyle == item,
onPressed: () {
ref.read(proxiesStyleSettingProvider.notifier).update((
@@ -248,37 +282,62 @@ class ProxiesSetting extends StatelessWidget {
);
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._buildStyleSetting(context),
..._buildSortSetting(context),
..._buildLayoutSetting(context),
..._buildSizeSetting(context),
Consumer(
builder: (_, ref, child) {
final isList = ref.watch(
proxiesStyleSettingProvider.select(
(state) => state.type == ProxiesType.list,
),
);
if (isList) {
return child!;
}
return Container();
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [..._buildGroupStyleSetting(context)],
),
List<Widget> _buildFilterSetting(BuildContext context) {
final appLocalizations = context.appLocalizations;
return generateSection(
title: appLocalizations.filter,
items: [
ConfigToggleItem(
title: (l) => l.hideTimeoutProxies,
subtitle: (l) => l.hideTimeoutProxiesDesc,
selector: proxiesStyleSettingProvider.select(
(state) => state.hideTimeoutProxies,
),
],
onChanged: (ref, value) {
ref
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(hideTimeoutProxies: value));
},
),
],
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: ref.sheetHeight(context, 0.7)),
child: SingleChildScrollView(
padding: EdgeInsets.only(top: context.contentTopPadding, bottom: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._buildStyleSetting(context),
..._buildSortSetting(context),
..._buildLayoutSetting(context),
..._buildSizeSetting(context),
Consumer(
builder: (_, ref, child) {
final isList = ref.watch(
proxiesStyleSettingProvider.select(
(state) => state.type == ProxiesType.list,
),
);
if (isList) {
return child!;
}
return Container();
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [..._buildGroupStyleSetting(context)],
),
),
..._buildFilterSetting(context),
],
),
),
);
}
+192 -245
View File
@@ -3,6 +3,7 @@ import 'dart:math';
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/models/common.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -12,9 +13,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'card.dart';
import 'common.dart';
typedef ProxyGroupViewKeyMap =
Map<String, GlobalObjectKey<_ProxyGroupViewState>>;
class ProxiesTabView extends ConsumerStatefulWidget {
const ProxiesTabView({super.key});
@@ -28,7 +26,8 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
with TickerProviderStateMixin {
TabController? _tabController;
final _hasMoreButtonNotifier = ValueNotifier<bool>(false);
ProxyGroupViewKeyMap _keyMap = {};
final Map<String, ScrollController> _scrollControllers = {};
int _columns = 1;
@override
void initState() {
@@ -50,15 +49,49 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
void dispose() {
_destroyTabController();
_hasMoreButtonNotifier.dispose();
for (final controller in _scrollControllers.values) {
controller.dispose();
}
super.dispose();
}
ScrollController _scrollControllerOf(String groupName) {
return _scrollControllers.putIfAbsent(groupName, ScrollController.new);
}
// The page of a dropped group still holds its controller until this frame
// unmounts it.
void _pruneScrollControllers(List<Group> groups) {
final names = {for (final group in groups) group.name};
final dropped = [
for (final entry in _scrollControllers.entries)
if (!names.contains(entry.key)) entry,
];
for (final entry in dropped) {
_scrollControllers.remove(entry.key);
WidgetsBinding.instance.addPostFrameCallback(
(_) => entry.value.dispose(),
);
}
}
void scrollToGroupSelected() {
final group = currentGroup;
if (group == null) {
final controller = _scrollControllers[group?.name];
if (group == null || controller == null) {
return;
}
_keyMap[group.name]?.currentState?.scrollToSelected();
final cardType = ref.read(proxiesTabStateProvider).proxyCardType;
final rowOffset = selectedRowOffset(
proxies: group.all,
selectedProxyName: ref.read(selectedProxyNameProvider(group.name)),
columns: _columns,
rowExtent: getRowExtent(cardType),
);
if (rowOffset == null) {
return;
}
animateScrollTo(controller, rowOffset);
}
Future<void> delayTestCurrentGroup() async {
@@ -68,7 +101,7 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
}
await ref
.read(proxiesActionProvider.notifier)
.delayTest(group.all, group.testUrl);
.delayTestPageGroup(group.name);
}
Group? get currentGroup {
@@ -90,54 +123,77 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
return IconButton(
tooltip: context.appLocalizations.more,
onPressed: _showMoreMenu,
iconSize: 20,
icon: isMobileView
? const Icon(Icons.expand_more)
: const Icon(Icons.chevron_right),
? const GlyphIcon(AppGlyphs.chevronDown)
: const GlyphIcon(AppGlyphs.chevronForward),
);
},
);
}
// A mask rather than a surface-colored cover holds on any page background;
// at full scroll the fade lands in the last tab's trailing label padding.
Shader _buildTabsMask(Rect bounds, bool hasMore) {
final coverStart = bounds.width - kMinInteractiveDimension;
final fadeStart = coverStart - kTabLabelPadding.right;
if (!hasMore || fadeStart <= 0) {
return const LinearGradient(
colors: [Colors.black, Colors.black],
).createShader(bounds);
}
return LinearGradient(
begin: AlignmentDirectional.centerStart,
end: AlignmentDirectional.centerEnd,
colors: [Colors.black, Colors.transparent],
stops: [fadeStart / bounds.width, coverStart / bounds.width],
).createShader(bounds, textDirection: Directionality.of(context));
}
void _showMoreMenu() {
showSheet(
context: context,
props: const SheetProps(isScrollControlled: false),
builder: (_) {
return AdaptiveSheetScaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Consumer(
builder: (_, ref, _) {
final state = ref.watch(proxiesTabControllerStateProvider);
final groupNames = state.groupNames;
final currentGroupName = state.currentGroupName;
return SizedBox(
width: double.infinity,
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 8,
spacing: 8,
children: [
for (final groupName in groupNames)
SettingTextCard(
groupName,
onPressed: () {
final index = groupNames.indexWhere(
(item) => item == groupName,
);
if (index == -1) return;
_tabController?.animateTo(index);
ref
.read(proxiesActionProvider.notifier)
.updateCurrentGroupName(groupName);
Navigator.of(context).pop();
},
isSelected: groupName == currentGroupName,
),
],
),
);
},
return CommonScaffold(
body: Builder(
builder: (context) => SingleChildScrollView(
padding: const EdgeInsets.all(
16,
).copyWith(top: context.contentTopPadding),
child: Consumer(
builder: (_, ref, _) {
final state = ref.watch(proxiesTabControllerStateProvider);
final groupNames = state.groupNames;
final currentGroupName = state.currentGroupName;
return SizedBox(
width: double.infinity,
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 8,
spacing: 8,
children: [
for (final groupName in groupNames)
SettingTextCard(
groupName,
onPressed: () {
final index = groupNames.indexWhere(
(item) => item == groupName,
);
if (index == -1) return;
_tabController?.animateTo(index);
ref
.read(proxiesActionProvider.notifier)
.updateCurrentGroupName(groupName);
Navigator.of(context).pop();
},
isSelected: groupName == currentGroupName,
),
],
),
);
},
),
),
),
title: context.appLocalizations.proxyGroup,
@@ -193,134 +249,121 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
final proxiesLayout = ref.watch(
proxiesStyleSettingProvider.select((state) => state.layout),
);
final isSearching = ref.watch(
queryProvider(
QueryTag.proxies,
).select((query) => SearchQuery(query).isNotEmpty),
);
final groups = state.groups;
_keyMap = {};
_pruneScrollControllers(groups);
return NullStatusSwitcher(
isEmpty: groups.isEmpty || _tabController == null,
isSearching: isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.proxies,
label: appLocalizations.nullTip(appLocalizations.proxies),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NotificationListener<ScrollMetricsNotification>(
onNotification: (scrollNotification) {
_hasMoreButtonNotifier.value =
scrollNotification.metrics.maxScrollExtent > 0;
return false;
},
child: ValueListenableBuilder(
valueListenable: _hasMoreButtonNotifier,
builder: (_, value, child) {
return Stack(
alignment: AlignmentDirectional.centerStart,
children: [
TabBar(
controller: _tabController,
padding: EdgeInsets.only(
left: 16,
right: 16 + (value ? 16 : 0),
),
dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
for (final group in groups)
Tab(
child: Builder(
builder: (context) {
return EmojiText(
group.name,
style: DefaultTextStyle.of(context).style,
);
},
),
),
],
),
if (value) Positioned(right: 0, child: child!),
],
);
child: AppBarClearance(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NotificationListener<ScrollMetricsNotification>(
onNotification: (scrollNotification) {
_hasMoreButtonNotifier.value =
scrollNotification.metrics.maxScrollExtent > 0;
return false;
},
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
context.colorScheme.surface.opacity10,
context.colorScheme.surface,
child: ValueListenableBuilder(
valueListenable: _hasMoreButtonNotifier,
builder: (_, hasMore, moreButton) {
return Stack(
alignment: AlignmentDirectional.centerEnd,
children: [
ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) =>
_buildTabsMask(bounds, hasMore),
child: TabBar(
controller: _tabController,
padding: EdgeInsetsDirectional.only(
start: 16,
end: hasMore ? kMinInteractiveDimension : 16,
),
dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
for (final group in groups)
Tab(
child: Builder(
builder: (context) {
return EmojiText(
group.name,
style: DefaultTextStyle.of(context).style,
);
},
),
),
],
),
),
if (hasMore) moreButton!,
],
stops: const [0.0, 0.1],
),
),
);
},
child: _buildMoreButton(),
),
),
),
Expanded(
child: LayoutBuilder(
builder: (_, constraints) {
final columns = getProxiesColumns(
max(constraints.maxWidth - 32, 0),
proxiesLayout,
);
return TabBarView(
controller: _tabController,
children: [
for (final group in groups)
ProxyGroupView(
key: _keyMap.updateCacheValue(
group.name,
() =>
GlobalObjectKey<_ProxyGroupViewState>(group.name),
Expanded(
child: LayoutBuilder(
builder: (_, constraints) {
final columns = getProxiesColumns(
max(constraints.maxWidth - 32, 0),
proxiesLayout,
);
_columns = columns;
return TabBarView(
controller: _tabController,
children: [
for (final group in groups)
ProxyGroupView(
key: ValueKey(group.name),
controller: _scrollControllerOf(group.name),
group: group,
columns: columns,
cardType: state.proxyCardType,
),
group: group,
columns: columns,
cardType: state.proxyCardType,
),
],
);
},
],
);
},
),
),
),
],
],
),
),
);
}
}
class ProxyGroupView extends ConsumerStatefulWidget {
class ProxyGroupView extends ConsumerWidget {
final Group group;
final ScrollController controller;
final int columns;
final ProxyCardType cardType;
const ProxyGroupView({
super.key,
required this.group,
required this.controller,
required this.columns,
required this.cardType,
});
@override
ConsumerState<ProxyGroupView> createState() => _ProxyGroupViewState();
}
class _ProxyGroupViewState extends ConsumerState<ProxyGroupView> {
late final ScrollController _controller;
@override
void initState() {
super.initState();
_controller = ScrollController();
}
PageStorageKey _getPageStorageKey() {
PageStorageKey _getPageStorageKey(WidgetRef ref) {
final profile = ref.read(currentProfileProvider);
final key =
'${profile?.id}_${ScrollPositionCacheKey.proxiesTabList.name}_${widget.group.name}';
'${profile?.id}_${ScrollPositionCacheKey.proxiesTabList.name}_${group.name}';
return ProxiesTabView.pageListStoreMap.updateCacheValue(
key,
() => PageStorageKey(key),
@@ -328,40 +371,14 @@ class _ProxyGroupViewState extends ConsumerState<ProxyGroupView> {
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void scrollToSelected() {
if (_controller.position.maxScrollExtent == 0) {
return;
}
_controller.animateTo(
min(
16 +
getScrollToSelectedOffset(
ref: ref,
groupName: widget.group.name,
proxies: widget.group.all,
columns: widget.columns,
),
_controller.position.maxScrollExtent,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn,
);
}
@override
Widget build(BuildContext context) {
final group = widget.group;
Widget build(BuildContext context, WidgetRef ref) {
final proxies = group.all;
return CommonScrollBar(
controller: _controller,
controller: controller,
thumbVisibility: true,
child: GridView.builder(
key: _getPageStorageKey(),
controller: _controller,
key: _getPageStorageKey(ref),
controller: controller,
padding: EdgeInsets.only(
top: 16,
left: 16,
@@ -369,10 +386,10 @@ class _ProxyGroupViewState extends ConsumerState<ProxyGroupView> {
bottom: 16 + BottomInsetScope.of(context),
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.columns,
mainAxisSpacing: 8,
crossAxisCount: columns,
mainAxisSpacing: proxyGridSpacing,
crossAxisSpacing: 8,
mainAxisExtent: getItemHeight(widget.cardType),
mainAxisExtent: getItemHeight(cardType),
),
itemCount: proxies.length,
itemBuilder: (_, index) {
@@ -380,7 +397,7 @@ class _ProxyGroupViewState extends ConsumerState<ProxyGroupView> {
return ProxyCard(
testUrl: group.testUrl,
groupType: group.type,
type: widget.cardType,
type: cardType,
proxy: proxy,
groupName: group.name,
);
@@ -389,73 +406,3 @@ class _ProxyGroupViewState extends ConsumerState<ProxyGroupView> {
);
}
}
class DelayTestButton extends StatefulWidget {
final Future Function() onClick;
const DelayTestButton({super.key, required this.onClick});
@override
State<DelayTestButton> createState() => _DelayTestButtonState();
}
class _DelayTestButtonState extends State<DelayTestButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
bool _running = false;
Future<void> _healthcheck() async {
if (_running) {
return;
}
_running = true;
unawaited(_controller.forward());
try {
await widget.onClick();
} finally {
_running = false;
if (mounted) {
unawaited(_controller.reverse());
}
}
}
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_animation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutBack),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return AnimatedBuilder(
animation: _controller.view,
builder: (_, child) {
return FadeTransition(
opacity: _animation,
child: ScaleTransition(scale: _animation, child: child),
);
},
child: CommonFloatingActionButton(
onPressed: _healthcheck,
label: appLocalizations.delayTest,
icon: const Icon(Icons.network_ping),
),
);
}
}
+5 -4
View File
@@ -3,6 +3,7 @@ import 'dart:io';
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/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
@@ -80,7 +81,7 @@ class ResourcesView extends ConsumerWidget {
body: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 16),
).copyWith(top: context.appBarInset, bottom: 16),
children: [
generateSectionV3(
title: appLocalizations.geoOptions,
@@ -207,14 +208,14 @@ class _GeoResourceListItemState extends ConsumerState<_GeoResourceListItem> {
final appLocalizations = context.appLocalizations;
return [
CommonPopupMenuItem(
icon: Icons.edit_outlined,
glyph: AppGlyphs.edit,
label: appLocalizations.edit,
onPressed: () {
_updateUrl(url);
},
),
CommonPopupMenuItem(
icon: Icons.sync,
glyph: AppGlyphs.sync,
label: appLocalizations.sync,
onPressed: _handleUpdateGeoDataItem,
),
@@ -283,7 +284,7 @@ class _GeoResourceListItemState extends ConsumerState<_GeoResourceListItem> {
return IconButton(
tooltip: context.appLocalizations.more,
onPressed: open,
icon: const Icon(Icons.more_vert),
icon: const GlyphIcon(AppGlyphs.more),
);
},
),
+1
View File
@@ -5,6 +5,7 @@ export 'connection/connections.dart';
export 'connection/requests.dart';
export 'dashboard/dashboard.dart';
export 'developer.dart';
export 'disclaimer.dart';
export 'dns_queries.dart';
export 'logs.dart';
export 'profiles/profiles.dart';
+45 -2
View File
@@ -1,4 +1,5 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:material_ui/material_ui.dart';
class CommonChip extends StatelessWidget {
@@ -39,9 +40,15 @@ class CommonChip extends StatelessWidget {
),
),
if (onDeleted != null)
GestureDetector(
InkWell(
onTap: onDeleted,
child: Icon(Icons.close, size: 14, color: foregroundColor),
customBorder: AppShape.circle,
focusColor: colorScheme.primary.opacity30,
child: GlyphIcon(
AppGlyphs.close,
size: 14,
color: foregroundColor,
),
),
],
),
@@ -59,6 +66,42 @@ class CommonChip extends StatelessWidget {
}
}
class TonalChip extends StatelessWidget {
final String label;
final Color color;
final Color foregroundColor;
final VoidCallback? onPressed;
const TonalChip({
super.key,
required this.label,
required this.color,
required this.foregroundColor,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final content = Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(color: foregroundColor),
),
);
return Material(
color: color,
shape: AppShape.sm,
clipBehavior: Clip.antiAlias,
child: onPressed == null
? content
: InkWell(onTap: onPressed, child: content),
);
}
}
class MetaChip extends StatelessWidget {
final String label;
+149 -43
View File
@@ -1,28 +1,53 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:fl_clash/common/cache.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_svg/svg.dart';
const _maxDecodedIcons = 64;
const _maxCachedIcons = 64;
final _decodedIcons = <String, Uint8List?>{};
class _IconBytesCache {
_IconBytesCache(this.capacity);
final int capacity;
final _entries = <String, Uint8List?>{};
bool contains(String key) => _entries.containsKey(key);
Uint8List? operator [](String key) {
if (!_entries.containsKey(key)) {
return null;
}
return _entries[key] = _entries.remove(key);
}
void operator []=(String key, Uint8List? value) {
_entries.remove(key);
if (_entries.length >= capacity) {
_entries.remove(_entries.keys.first);
}
_entries[key] = value;
}
void remove(String key) => _entries.remove(key);
}
final _decodedIcons = _IconBytesCache(_maxCachedIcons);
final _remoteIcons = _IconBytesCache(_maxCachedIcons);
Uint8List? _decodeIcon(String src) {
if (!src.contains('base64,')) {
return null;
}
if (_decodedIcons.containsKey(src)) {
return _decodedIcons[src] = _decodedIcons.remove(src);
}
if (_decodedIcons.length >= _maxDecodedIcons) {
_decodedIcons.remove(_decodedIcons.keys.first);
if (_decodedIcons.contains(src)) {
return _decodedIcons[src];
}
return _decodedIcons[src] = src.getBase64;
}
@@ -33,7 +58,7 @@ class CommonTargetIcon extends StatelessWidget {
const CommonTargetIcon({super.key, required this.src});
Widget _defaultIcon() {
return const Icon(IconsExt.target);
return const GlyphIcon(AppGlyphs.target);
}
Widget _buildIcon() {
@@ -43,13 +68,7 @@ class CommonTargetIcon extends StatelessWidget {
final base64 = _decodeIcon(src);
if (base64 != null) {
return Image.memory(
base64,
gaplessPlayback: true,
errorBuilder: (_, error, _) {
return _defaultIcon();
},
);
return _BytesImage(bytes: base64, isSvg: false, fallback: _defaultIcon());
}
return ImageCacheWidget(src: src, defaultWidget: _defaultIcon());
@@ -61,8 +80,97 @@ class CommonTargetIcon extends StatelessWidget {
}
}
class _BytesImage extends StatelessWidget {
const _BytesImage({
required this.bytes,
required this.isSvg,
required this.fallback,
this.onError,
});
final Uint8List bytes;
final bool isSvg;
final Widget fallback;
final VoidCallback? onError;
Widget _buildFallback() {
onError?.call();
return fallback;
}
@override
Widget build(BuildContext context) {
return isSvg
? SvgPicture.memory(bytes, errorBuilder: (_, _, _) => _buildFallback())
: Image.memory(
bytes,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _buildFallback(),
);
}
}
final _cacheMange = DefaultCacheManager();
Stream<Uint8List> _loadRemoteIcon(String src) {
return _cacheMange
.getFileStreamV2(
src,
onRemoteNewLoaded: () {
commonPrint.log('The icon has been recorded: $src');
database.iconRecordsDao.putIfAbsent(src);
},
)
.asyncMap((data) => data.file.readAsBytes())
.map((bytes) {
final current = _remoteIcons[src];
final next = current != null && listEquals(current, bytes)
? current
: bytes;
return _remoteIcons[src] = next;
});
}
Future<void> _decodeAhead(Uint8List bytes) {
final completer = Completer<void>();
final stream = MemoryImage(bytes).resolve(ImageConfiguration.empty);
late final ImageStreamListener listener;
void finish() {
stream.removeListener(listener);
completer.complete();
}
listener = ImageStreamListener(
(_, _) => finish(),
onError: (_, _) => finish(),
);
stream.addListener(listener);
return completer.future;
}
Future<void> precacheTargetIcons(Iterable<String> srcs) async {
final pending = srcs
.toSet()
.where(
(src) =>
src.isNotEmpty &&
!src.contains('base64,') &&
!_remoteIcons.contains(src),
)
.map((src) async {
try {
await for (final bytes in _loadRemoteIcon(src)) {
if (!src.isSvg) {
await _decodeAhead(bytes);
}
}
} catch (error) {
commonPrint.log('Failed to precache icon $src: $error');
}
});
await Future.wait(pending);
}
class ImageCacheWidget extends StatefulWidget {
final String src;
final Widget defaultWidget;
@@ -78,12 +186,13 @@ class ImageCacheWidget extends StatefulWidget {
}
class _ImageCacheWidgetState extends State<ImageCacheWidget> {
final ValueNotifier<File?> _imageNotifier = ValueNotifier(null);
late final ValueNotifier<Uint8List?> _bytesNotifier;
StreamSubscription? _streamSubscription;
@override
void initState() {
super.initState();
_bytesNotifier = ValueNotifier(_remoteIcons[widget.src]);
_getImageFormCache();
}
@@ -91,53 +200,50 @@ class _ImageCacheWidgetState extends State<ImageCacheWidget> {
void didUpdateWidget(covariant ImageCacheWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.src != widget.src) {
_bytesNotifier.value = _remoteIcons[widget.src];
_getImageFormCache();
}
}
void _getImageFormCache() {
_imageNotifier.value = null;
final src = widget.src;
_streamSubscription?.cancel();
_streamSubscription = null;
if (src.isEmpty) {
return;
}
_streamSubscription?.cancel();
_streamSubscription = _cacheMange
.getFileStreamV2(
src,
onRemoteNewLoaded: () {
commonPrint.log('The icon has been recorded: $src');
database.iconRecordsDao.putIfAbsent(src);
},
)
.listen((data) {
if (mounted) {
_imageNotifier.value = data.file;
}
});
_streamSubscription = _loadRemoteIcon(src).listen(
(bytes) {
if (mounted) {
_bytesNotifier.value = bytes;
}
},
onError: (Object error) {
commonPrint.log('Failed to read icon $src: $error');
},
);
}
@override
void dispose() {
_streamSubscription?.cancel();
_imageNotifier.dispose();
_bytesNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<File?>(
valueListenable: _imageNotifier,
builder: (_, data, _) {
if (data == null) {
return ValueListenableBuilder<Uint8List?>(
valueListenable: _bytesNotifier,
builder: (_, bytes, _) {
if (bytes == null) {
return widget.defaultWidget;
}
return CommonImage(
data: data,
return _BytesImage(
bytes: bytes,
isSvg: widget.src.isSvg,
errorBuilder: (_, _, _) {
return widget.defaultWidget;
},
fallback: widget.defaultWidget,
onError: () => _remoteIcons.remove(widget.src),
);
},
);
@@ -238,6 +344,6 @@ class CommonImage extends StatelessWidget {
Widget build(BuildContext context) {
return isSvg
? SvgPicture.file(data, errorBuilder: errorBuilder)
: Image.file(data, errorBuilder: errorBuilder);
: Image.file(data, gaplessPlayback: true, errorBuilder: errorBuilder);
}
}
+61 -61
View File
@@ -4,6 +4,9 @@ import 'package:material_ui/material_ui.dart';
const _defaultDuration = Duration(milliseconds: 300);
// Rows sized towards zero all fit the viewport, so each one is laid out.
const _maxAnimatedChanges = 50;
/// A lazily built vertical list that diffs [items] by key: removed items
/// collapse out, inserted items grow in, and items whose key survives slide
/// from their previous slot to the new one.
@@ -31,20 +34,39 @@ class KeyedAnimatedList<T> extends StatefulWidget {
State<KeyedAnimatedList<T>> createState() => _KeyedAnimatedListState<T>();
}
enum _Phase { settled, entering, leaving }
class _Entry<T> {
final Object key;
T item;
AnimationController? controller;
bool removing = false;
_Phase phase = _Phase.settled;
_Entry(this.key, this.item);
}
class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
with TickerProviderStateMixin {
with SingleTickerProviderStateMixin {
List<_Entry<T>> _entries = [];
int _generation = 0;
// One controller drives every row entering or leaving in an update, so a
// refresh that touches rows far off screen still runs a single ticker.
late final AnimationController _transition =
AnimationController(vsync: this, duration: widget.duration)
..addStatusListener((status) {
if (status.isCompleted) {
setState(_settle);
}
});
late final CurvedAnimation _entering = CurvedAnimation(
parent: _transition,
curve: Curves.easeOutCubic,
);
late final CurvedAnimation _leaving = CurvedAnimation(
parent: ReverseAnimation(_transition),
curve: Curves.easeInCubic,
);
@override
void initState() {
super.initState();
@@ -56,6 +78,7 @@ class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
@override
void didUpdateWidget(covariant KeyedAnimatedList<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_transition.duration = widget.duration;
if (!identical(oldWidget.items, widget.items)) {
_applyItems(widget.items);
}
@@ -63,47 +86,43 @@ class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
@override
void dispose() {
for (final entry in _entries) {
entry.controller?.dispose();
}
_entering.dispose();
_leaving.dispose();
_transition.dispose();
super.dispose();
}
AnimationController _createController(
_Entry<T> entry, {
required double from,
}) {
final controller = AnimationController(
vsync: this,
duration: widget.duration,
value: from,
);
controller.addStatusListener((status) {
if (!mounted) {
return;
}
if (!status.isAnimating) {
setState(() {
entry.controller = null;
if (status.isDismissed) {
_entries.remove(entry);
}
});
SchedulerBinding.instance.addPostFrameCallback((_) {
controller.dispose();
});
}
void _settle() {
_entries.removeWhere((entry) => entry.phase == _Phase.leaving);
for (final entry in _entries) {
entry.phase = _Phase.settled;
}
}
void _replaceItems(List<T> items) {
_transition.stop();
setState(() {
_entries = [for (final item in items) _Entry(widget.keyOf(item), item)];
_generation++;
});
return controller;
}
void _applyItems(List<T> items) {
if (_transition.isAnimating) {
_transition.stop();
_settle();
}
final oldByKey = {for (final entry in _entries) entry.key: entry};
final newKeys = {for (final item in items) widget.keyOf(item)};
final inserted = newKeys.where((key) => !oldByKey.containsKey(key));
final removed = _entries.where((entry) => !newKeys.contains(entry.key));
if (inserted.length + removed.length > _maxAnimatedChanges) {
_replaceItems(items);
return;
}
final removedBefore = <Object, List<_Entry<T>>>{};
var pending = <_Entry<T>>[];
final transitions = <VoidCallback>[];
for (final entry in _entries) {
if (newKeys.contains(entry.key)) {
if (pending.isNotEmpty) {
@@ -111,15 +130,8 @@ class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
pending = [];
}
} else {
entry.phase = _Phase.leaving;
pending.add(entry);
if (!entry.removing) {
entry.removing = true;
final controller = entry.controller ??= _createController(
entry,
from: 1,
);
transitions.add(controller.reverse);
}
}
}
@@ -129,21 +141,10 @@ class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
next.addAll(removedBefore[key] ?? const []);
final existing = oldByKey[key];
if (existing == null) {
final entry = _Entry(key, item);
final controller = _createController(entry, from: 0);
entry.controller = controller;
transitions.add(controller.forward);
next.add(entry);
next.add(_Entry(key, item)..phase = _Phase.entering);
continue;
}
existing.item = item;
if (existing.removing) {
existing.removing = false;
final controller = existing.controller;
if (controller != null) {
transitions.add(controller.forward);
}
}
next.add(existing);
}
next.addAll(pending);
@@ -152,8 +153,8 @@ class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
_entries = next;
_generation++;
});
for (final transition in transitions) {
transition();
if (next.any((entry) => entry.phase != _Phase.settled)) {
_transition.forward(from: 0);
}
}
@@ -164,13 +165,12 @@ class _KeyedAnimatedListState<T> extends State<KeyedAnimatedList<T>>
if (separator != null && index < _entries.length - 1) {
row = Column(mainAxisSize: MainAxisSize.min, children: [row, separator]);
}
final controller = entry.controller;
if (controller != null) {
final animation = CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
final animation = switch (entry.phase) {
_Phase.settled => null,
_Phase.entering => _entering,
_Phase.leaving => _leaving,
};
if (animation != null) {
row = SizeTransition(
sizeFactor: animation,
alignment: Alignment.topCenter,
+70 -65
View File
@@ -3,7 +3,6 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'card.dart';
@@ -45,26 +44,22 @@ final class _CheckboxAction extends _ListItemAction {
final class _OpenAction extends _ListItemAction {
final Widget widget;
final double? maxWidth;
final bool blur;
final bool forceFull;
final ValueChanged<dynamic>? onChanged;
final bool forceFull;
const _OpenAction({
required this.widget,
this.maxWidth,
required this.blur,
required this.forceFull,
this.onChanged,
required this.forceFull,
});
}
final class _NextAction extends _ListItemAction {
final Widget widget;
final double? maxWidth;
final bool blur;
const _NextAction({required this.widget, this.maxWidth, required this.blur});
const _NextAction({required this.widget, this.maxWidth});
}
final class _OptionsAction<T> extends _ListItemAction {
@@ -150,10 +145,8 @@ class ListItem<T> extends StatelessWidget {
this.padding = const EdgeInsets.symmetric(horizontal: 16),
this.trailing,
required Widget widget,
double? maxWidth,
bool blur = true,
bool forceFull = true,
ValueChanged<dynamic>? onChanged,
bool forceFull = true,
this.horizontalTitleGap,
this.dense,
this.titleTextStyle,
@@ -165,10 +158,8 @@ class ListItem<T> extends StatelessWidget {
this.tileTitleAlignment = ListTileTitleAlignment.center,
}) : _action = _OpenAction(
widget: widget,
maxWidth: maxWidth,
blur: blur,
forceFull: forceFull,
onChanged: onChanged,
forceFull: forceFull,
),
onTap = null;
@@ -181,7 +172,6 @@ class ListItem<T> extends StatelessWidget {
this.trailing,
required Widget widget,
double? maxWidth,
bool blur = true,
this.horizontalTitleGap,
this.dense,
this.titleTextStyle,
@@ -191,7 +181,7 @@ class ListItem<T> extends StatelessWidget {
this.visualDensity,
this.minVerticalPadding = 12,
this.tileTitleAlignment = ListTileTitleAlignment.center,
}) : _action = _NextAction(widget: widget, maxWidth: maxWidth, blur: blur),
}) : _action = _NextAction(widget: widget, maxWidth: maxWidth),
onTap = null;
ListItem.options({
@@ -324,10 +314,26 @@ class ListItem<T> extends StatelessWidget {
onTap = null;
Widget _buildListTile({
required ItemPosition? position,
void Function()? onTap,
Widget? trailing,
Widget? leading,
}) {
if (position != null) {
// OpenContainer reparents the closed tile out of the section's provider.
return ItemPositionProvider(
position: position,
child: DecorationListItem(
leading: leading ?? this.leading,
title: title,
subtitle: subtitle,
trailing: trailing ?? this.trailing,
contentPadding: padding,
horizontalTitleGap: horizontalTitleGap,
onPressed: onTap,
),
);
}
return ListTile(
key: key,
dense: dense,
@@ -350,35 +356,27 @@ class ListItem<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final position = ItemPositionProvider.of(context)?.position;
switch (_action) {
case final _OpenAction openDelegate:
final child = openDelegate.widget;
final onChanged = openDelegate.onChanged;
if (!context.isMobileView) {
return _buildListTile(
position: position,
onTap: () async {
final result = await showExtend<dynamic>(
context,
props: ExtendProps(forceFull: openDelegate.forceFull),
builder: (_) => child,
);
onChanged?.call(result);
},
);
}
return OpenContainer<dynamic>(
closedBuilder: (context, action) {
Future<void> openAction() async {
final isMobile = context.isMobileView;
if (!isMobile || kDebugMode) {
final res = await showExtend(
context,
props: ExtendProps(
blur: openDelegate.blur,
maxWidth: openDelegate.maxWidth,
forceFull: openDelegate.forceFull,
),
builder: (_) {
return child;
},
);
if (onChanged != null) {
onChanged(res);
}
return;
}
action();
}
return _buildListTile(onTap: openAction);
return _buildListTile(position: position, onTap: action);
},
onClosed: onChanged,
openBuilder: (_, action) {
@@ -389,13 +387,11 @@ class ListItem<T> extends StatelessWidget {
final child = nextDelegate.widget;
return _buildListTile(
position: position,
onTap: () {
showExtend(
context,
props: ExtendProps(
blur: nextDelegate.blur,
maxWidth: nextDelegate.maxWidth,
),
props: ExtendProps(maxWidth: nextDelegate.maxWidth),
builder: (_) {
return child;
},
@@ -405,20 +401,29 @@ class ListItem<T> extends StatelessWidget {
case final _OptionsAction options:
final optionsDelegate = options as _OptionsAction<T>;
return _buildListTile(
position: position,
onTap: () async {
final value = await dialogs.showCommonDialog<T>(
child: OptionsDialog<T>(
// Options are boxed so that a nullable option such as the default
// locale stays distinct from the null a dismissed dialog returns.
final selected = await dialogs.showCommonDialog<(T,)>(
child: OptionsDialog<(T,)>(
title: optionsDelegate.title,
options: optionsDelegate.options,
textBuilder: optionsDelegate.textBuilder,
value: optionsDelegate.value,
options: [
for (final option in optionsDelegate.options) (option,),
],
textBuilder: (option) => optionsDelegate.textBuilder(option.$1),
value: (optionsDelegate.value,),
),
);
optionsDelegate.onChanged(value);
if (selected == null) {
return;
}
optionsDelegate.onChanged(selected.$1);
},
);
case final _InputAction inputDelegate:
return _buildListTile(
position: position,
onTap: () async {
final value = await dialogs.showCommonDialog<String>(
child: InputDialog(
@@ -438,6 +443,7 @@ class ListItem<T> extends StatelessWidget {
);
case final _CheckboxAction checkboxDelegate:
return _buildListTile(
position: position,
onTap: checkboxDelegate.onChanged == null
? null
: () {
@@ -450,6 +456,7 @@ class ListItem<T> extends StatelessWidget {
);
case final _ToggleAction toggleAction:
return _buildListTile(
position: position,
onTap: toggleAction.onChanged == null
? null
: () {
@@ -463,6 +470,7 @@ class ListItem<T> extends StatelessWidget {
case final _RadioAction radio:
final radioDelegate = radio as _RadioAction<T>;
return _buildListTile(
position: position,
onTap: radioDelegate.onTap,
leading: ExcludeFocus(
child: Radio<T>(
@@ -475,7 +483,7 @@ class ListItem<T> extends StatelessWidget {
trailing: trailing,
);
case _DefaultAction():
return _buildListTile(onTap: onTap);
return _buildListTile(position: position, onTap: onTap);
}
}
}
@@ -504,9 +512,6 @@ class ListHeader extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: subTitle == null
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
spacing: actions.isEmpty ? 0 : 12,
children: [
Expanded(
@@ -535,7 +540,8 @@ class ListHeader extends StatelessWidget {
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [...genActions(actions, space: space)],
spacing: space ?? appBarActionSpace,
children: [...actions],
),
],
),
@@ -589,7 +595,7 @@ Widget generateSectionV2({
if (items.isNotEmpty && title != null)
ListHeader(title: title, actions: actions),
ClipRSuperellipse(
borderRadius: AppRadius.md,
borderRadius: AppRadius.xl,
child: Column(children: [...genItems]),
),
],
@@ -601,13 +607,12 @@ Widget generateSectionV3({
required Iterable<Widget> items,
List<Widget>? actions,
}) {
final genItems = items.mapIndexed<Widget>((index, item) {
final position = ItemPosition.get(index, items.length);
if (position != ItemPosition.middle) {
return ItemPositionProvider(position: position, child: item);
}
return item;
});
final genItems = items.mapIndexed<Widget>(
(index, item) => ItemPositionProvider(
position: ItemPosition.get(index, items.length),
child: item,
),
);
return Column(
children: [
if (items.isNotEmpty && title != null)
@@ -632,10 +637,10 @@ List<Widget> generateInfoSection({
];
}
Widget generateListView(List<Widget> items) {
Widget generateListView(List<Widget> items, {double topPadding = 0}) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (_, index) => items[index],
padding: const EdgeInsets.only(bottom: 16),
padding: EdgeInsets.only(top: topPadding, bottom: 16),
);
}
+12 -3
View File
@@ -59,6 +59,8 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
MaterialShapes.oval,
];
static final List<Morph> _defaultMorphs = _buildMorphs(_defaultShapeSequence);
final SpringSimulation _morphAnimation = SpringSimulation(
SpringDescription.withDampingRatio(mass: 1, stiffness: 200, ratio: 0.6),
0,
@@ -124,7 +126,7 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
widget.containerColor ?? colorScheme.primaryContainer,
};
final shapeSequence = widget.polygons ?? _defaultShapeSequence;
final morphs = _morphsFor(shapeSequence);
final morphs = _morphsFor(widget.polygons);
final padding = (widget.padding ?? EdgeInsets.zero).resolve(
Directionality.of(context),
);
@@ -192,13 +194,20 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
int get _shapeCount =>
widget.polygons?.length ?? _defaultShapeSequence.length;
List<Morph> _morphsFor(List<RoundedPolygon> polygons) {
List<Morph> _morphsFor(List<RoundedPolygon>? polygons) {
if (polygons == null) {
return _defaultMorphs;
}
final cachedMorphs = _cachedMorphs;
if (cachedMorphs != null && listEquals(_cachedPolygons, polygons)) {
return cachedMorphs;
}
_cachedPolygons = polygons;
return _cachedMorphs = [
return _cachedMorphs = _buildMorphs(polygons);
}
static List<Morph> _buildMorphs(List<RoundedPolygon> polygons) {
return [
for (var i = 0; i < polygons.length; i++)
Morph(polygons[i], polygons[(i + 1) % polygons.length]),
];
+213
View File
@@ -0,0 +1,213 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'chip.dart';
import 'list.dart';
class RecordTextStyles {
final TextStyle? primary;
final TextStyle? secondary;
final TextStyle? muted;
const RecordTextStyles._({this.primary, this.secondary, this.muted});
factory RecordTextStyles.of(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
final secondary = textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
);
return RecordTextStyles._(
primary: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
secondary: secondary,
muted: secondary?.copyWith(color: colorScheme.outline),
);
}
}
class RecordListItem extends StatelessWidget {
final Widget header;
final Widget body;
final RecordTone tone;
final VoidCallback? onTap;
const RecordListItem({
super.key,
required this.header,
required this.body,
this.tone = RecordTone.neutral,
this.onTap,
});
@override
Widget build(BuildContext context) {
final item = ListItem(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
minVerticalPadding: 0,
minTileHeight: 0,
horizontalTitleGap: 12,
tileTitleAlignment: ListTileTitleAlignment.top,
color: tone.tintColor(context),
onTap: onTap,
title: header,
subtitle: Padding(padding: const EdgeInsets.only(top: 6), child: body),
);
final accentColor = tone.accentColor(context);
if (accentColor == null) {
return item;
}
return DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(left: BorderSide(color: accentColor, width: 3)),
),
child: item,
);
}
}
class RecordHeader extends StatelessWidget {
final List<Widget> children;
final Widget? trailing;
const RecordHeader({super.key, required this.children, this.trailing});
@override
Widget build(BuildContext context) {
final trailing = this.trailing;
return DefaultTextStyle.merge(
style: context.textTheme.labelMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()],
),
child: SizedBox(
width: double.infinity,
child: Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 12,
runSpacing: 4,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: children,
),
?trailing,
],
),
),
);
}
}
class RecordLabel extends StatelessWidget {
final String label;
final RecordTone tone;
final VoidCallback? onPressed;
const RecordLabel({
super.key,
required this.label,
this.tone = RecordTone.neutral,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return TonalChip(
label: label,
color: tone.labelContainerColor(context),
foregroundColor: tone.labelColor(context),
onPressed: onPressed,
);
}
}
class RecordTimestamp extends StatelessWidget {
final String dateTime;
const RecordTimestamp(this.dateTime, {super.key});
@override
Widget build(BuildContext context) {
final split = dateTime.lastIndexOf(' ');
return Text.rich(
TextSpan(
children: [
if (split > 0)
TextSpan(
text: dateTime.substring(0, split + 1),
style: TextStyle(color: context.colorScheme.outline),
),
TextSpan(text: split > 0 ? dateTime.substring(split + 1) : dateTime),
],
),
maxLines: 1,
);
}
}
class RecordArrow extends StatelessWidget {
const RecordArrow({super.key});
@override
Widget build(BuildContext context) {
return Text(
'→',
style: RecordTextStyles.of(context).muted?.toJetBrainsMono,
);
}
}
class DetailRow extends StatelessWidget {
final String title;
final Widget? value;
final String? copyText;
const DetailRow({super.key, required this.title, this.value, this.copyText});
DetailRow.text({super.key, required this.title, required String value})
: value = Text(value),
copyText = value;
Future<void> _copy(BuildContext context, String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
context.showNotifier(context.appLocalizations.copySuccess);
}
@override
Widget build(BuildContext context) {
final value = this.value;
final copyText = this.copyText;
return DecorationListItem(
onPressed: copyText == null ? null : () => _copy(context, copyText),
title: value == null
? Text(title)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 20,
children: [
Text(title),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: DefaultTextStyle.merge(
textAlign: TextAlign.end,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
child: value,
),
),
),
],
),
);
}
}
+419 -108
View File
@@ -3,66 +3,353 @@ import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'inherited.dart';
const _thumbThickness = 6.0;
const _restThickness = 6.0;
const _activeThickness = 10.0;
const _crossAxisMargin = 2.0;
const _minThumbLength = 48.0;
const _fabZoneGap = 8.0;
// Every band the pointer strays sideways from where it grabbed the thumb
// halves how far the content moves per pixel of thumb travel.
const _scrubBandWidth = 48.0;
const _scrubRates = [1.0, 0.5, 0.25, 0.125];
class CommonScrollBar extends StatelessWidget {
final ScrollController? controller;
final Widget child;
final bool trackVisibility;
final bool thumbVisibility;
final EdgeInsets padding;
/// Reports the scrub rate while the thumb is held, and null on release.
final ValueChanged<double?>? onScrub;
final bool _hidesAmbientBar;
const CommonScrollBar({
super.key,
required this.child,
required this.controller,
this.trackVisibility = false,
this.thumbVisibility = false,
this.padding = EdgeInsets.zero,
});
this.onScrub,
}) : _hidesAmbientBar = true;
Widget _buildScrollBar(Widget child) {
return Scrollbar(
/// The bar a scroll behavior adds, which has no other bar to hide.
const CommonScrollBar.ambient({
super.key,
required this.child,
required this.controller,
this.padding = EdgeInsets.zero,
}) : thumbVisibility = false,
onScrub = null,
_hidesAmbientBar = false;
Widget _buildScrollBar(BuildContext context, Widget child) {
return _AppScrollbar(
controller: controller,
thumbVisibility: thumbVisibility,
trackVisibility: trackVisibility,
thickness: _thumbThickness,
radius: const Radius.circular(_thumbThickness / 2),
interactive: true,
child: child,
onScrub: onScrub,
child: _hidesAmbientBar
? ScrollConfiguration(
behavior: _OwnBarScrollBehavior(
ambient: ScrollConfiguration.of(context),
controller:
controller ?? PrimaryScrollController.maybeOf(context),
),
child: child,
)
: child,
);
}
@override
Widget build(BuildContext context) {
if (padding == EdgeInsets.zero) {
return _buildScrollBar(child);
return _buildScrollBar(context, child);
}
// Scrollbar insets its track by MediaQuery padding, so the inset reaches it
// through a MediaQuery of its own and the child keeps the original one.
final mediaQuery = MediaQuery.of(context);
return MediaQuery(
data: mediaQuery.copyWith(padding: mediaQuery.padding + padding),
child: _buildScrollBar(MediaQuery(data: mediaQuery, child: child)),
child: _buildScrollBar(
context,
MediaQuery(data: mediaQuery, child: child),
),
);
}
}
/// The ambient behavior less the bar it would stack on the scrollable that
/// [controller] drives, so lists nested inside keep theirs.
class _OwnBarScrollBehavior extends ScrollBehavior {
const _OwnBarScrollBehavior({
required this.ambient,
required this.controller,
});
final ScrollBehavior ambient;
final ScrollController? controller;
@override
Widget buildScrollbar(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
if (details.controller == controller) {
return child;
}
return ambient.buildScrollbar(context, child, details);
}
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) => ambient.buildOverscrollIndicator(context, child, details);
@override
Set<PointerDeviceKind> get dragDevices => ambient.dragDevices;
@override
Set<LogicalKeyboardKey> get pointerAxisModifiers =>
ambient.pointerAxisModifiers;
@override
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) =>
ambient.getMultitouchDragStrategy(context);
@override
TargetPlatform getPlatform(BuildContext context) =>
ambient.getPlatform(context);
@override
ScrollPhysics getScrollPhysics(BuildContext context) =>
ambient.getScrollPhysics(context);
@override
ScrollViewKeyboardDismissBehavior getKeyboardDismissBehavior(
BuildContext context,
) => ambient.getKeyboardDismissBehavior(context);
@override
GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) =>
ambient.velocityTrackerBuilder(context);
@override
bool shouldNotify(_OwnBarScrollBehavior oldDelegate) =>
oldDelegate.controller != controller ||
oldDelegate.ambient.runtimeType != ambient.runtimeType ||
ambient.shouldNotify(oldDelegate.ambient);
}
class _AppScrollbar extends RawScrollbar {
final ValueChanged<double?>? onScrub;
const _AppScrollbar({
required super.child,
super.controller,
super.thumbVisibility,
this.onScrub,
}) : super(
interactive: true,
shape: AppShape.full,
minThumbLength: _minThumbLength,
crossAxisMargin: _crossAxisMargin,
fadeDuration: const Duration(milliseconds: 250),
// Long enough to let go and grab the thumb again before it fades.
timeToFade: const Duration(milliseconds: 1200),
);
@override
RawScrollbarState<_AppScrollbar> createState() => _AppScrollbarState();
}
class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
late final AnimationController _emphasis;
late final CurvedAnimation _emphasisCurve;
late ColorScheme _colorScheme;
late TargetPlatform _platform;
var _hovered = false;
var _dragging = false;
Offset? _scrubOrigin;
Offset? _scrubLast;
Offset? _scrubPosition;
var _scrubRate = 1.0;
@override
void initState() {
super.initState();
_emphasis = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
)..addListener(updateScrollbarPainter);
_emphasisCurve = CurvedAnimation(parent: _emphasis, curve: Curves.easeOut);
}
@override
void didChangeDependencies() {
final theme = Theme.of(context);
_colorScheme = theme.colorScheme;
_platform = theme.platform;
super.didChangeDependencies();
}
@override
void dispose() {
_emphasisCurve.dispose();
_emphasis.dispose();
super.dispose();
}
void _syncEmphasis() {
if (_hovered || _dragging) {
_emphasis.forward();
} else {
_emphasis.reverse();
}
}
double _scrubRateFor(double sidewaysDistance) {
if (!isTouchPlatform(_platform)) {
return 1.0;
}
final band = (sidewaysDistance / _scrubBandWidth).floor();
return _scrubRates[band.clamp(0, _scrubRates.length - 1)];
}
@override
void updateScrollbarPainter() {
super.updateScrollbarPainter();
final t = _emphasisCurve.value;
final onSurface = _colorScheme.onSurface;
final isDark = _colorScheme.brightness == Brightness.dark;
scrollbarPainter
..color = Color.lerp(
onSurface.withValues(alpha: isDark ? 0.35 : 0.25),
onSurface.withValues(alpha: isDark ? 0.65 : 0.5),
t,
)!
..thickness = _restThickness + (_activeThickness - _restThickness) * t;
}
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
if (getScrollbarDirection() == null) {
return;
}
_dragging = true;
_scrubOrigin = localPosition;
_scrubLast = localPosition;
_scrubPosition = localPosition;
_scrubRate = 1.0;
_syncEmphasis();
if (isTouchPlatform(_platform)) {
HapticFeedback.selectionClick();
}
widget.onScrub?.call(_scrubRate);
}
// The thumb maps to an absolute offset, so a slowed scrub feeds the base
// class a virtual pointer that advances by the scaled delta only.
@override
void handleThumbPressUpdate(Offset localPosition) {
final origin = _scrubOrigin;
final last = _scrubLast;
final position = _scrubPosition;
final direction = getScrollbarDirection();
if (origin == null || last == null || position == null) {
super.handleThumbPressUpdate(localPosition);
return;
}
final Offset next;
final double rate;
switch (direction) {
case Axis.vertical:
rate = _scrubRateFor((localPosition.dx - origin.dx).abs());
next = Offset(
localPosition.dx,
position.dy + (localPosition.dy - last.dy) * rate,
);
case Axis.horizontal:
rate = _scrubRateFor((localPosition.dy - origin.dy).abs());
next = Offset(
position.dx + (localPosition.dx - last.dx) * rate,
localPosition.dy,
);
case null:
super.handleThumbPressUpdate(localPosition);
return;
}
_scrubLast = localPosition;
_scrubPosition = next;
if (rate != _scrubRate) {
_scrubRate = rate;
widget.onScrub?.call(rate);
}
super.handleThumbPressUpdate(next);
}
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
final position = _scrubPosition ?? localPosition;
final slowed = _scrubRate < 1;
_dragging = false;
_scrubOrigin = null;
_scrubLast = null;
_scrubPosition = null;
_scrubRate = 1.0;
_syncEmphasis();
// A fling would throw away the precision the slowed scrub just bought.
super.handleThumbPressEnd(position, slowed ? Velocity.zero : velocity);
widget.onScrub?.call(null);
}
@override
void handleHover(PointerHoverEvent event) {
super.handleHover(event);
final hovered = isPointerOverScrollbar(
event.position,
event.kind,
forHover: true,
);
if (hovered != _hovered) {
_hovered = hovered;
_syncEmphasis();
}
}
@override
void handleHoverExit(PointerExitEvent event) {
super.handleHoverExit(event);
if (_hovered) {
_hovered = false;
_syncEmphasis();
}
}
}
class FloatingScrollbar extends StatefulWidget {
final ScrollController controller;
final String Function(double fraction) hintBuilder;
final bool thumbVisibility;
final Widget child;
const FloatingScrollbar({
super.key,
required this.controller,
required this.hintBuilder,
this.thumbVisibility = false,
required this.child,
});
@@ -71,16 +358,17 @@ class FloatingScrollbar extends StatefulWidget {
}
class _FloatingScrollbarState extends State<FloatingScrollbar> {
var _shown = false;
var _hintVisible = false;
var _hintMounted = false;
double? _scrubRate;
var _pillHeight = 0.0;
Timer? _hideTimer;
@override
void initState() {
super.initState();
// Scroll notifications only fire on scroll activity; lazy lists also
// refine maxScrollExtent during layout, which moves the thumb without
// any notification. The position listener covers both.
// Lazy lists also refine maxScrollExtent during layout, which moves the
// thumb without a scroll notification; the position listener covers both.
widget.controller.addListener(_syncPosition);
}
@@ -101,43 +389,39 @@ class _FloatingScrollbarState extends State<FloatingScrollbar> {
}
void _syncPosition() {
if (_shown && mounted) {
if (_hintMounted && mounted) {
setState(() {});
}
}
// Deferred so transient ScrollEndNotifications mid-gesture do not blink
// the pill off and on.
void _scheduleHide() {
void _handleScrub(double? rate) {
_hideTimer?.cancel();
if (!_shown) return;
if (rate != null) {
final appearing = !_hintMounted;
setState(() {
_scrubRate = rate;
_hintVisible = !appearing;
_hintMounted = true;
});
if (appearing) {
// Mounts transparent so AnimatedOpacity has a value to fade from.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _hintMounted) {
setState(() => _hintVisible = true);
}
});
}
return;
}
setState(() => _scrubRate = null);
// Lingers so letting go and grabbing again does not blink the hint.
_hideTimer = Timer(const Duration(milliseconds: 500), () {
if (mounted) {
setState(() => _shown = false);
setState(() => _hintVisible = false);
}
});
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification is ScrollEndNotification) {
_scheduleHide();
return false;
}
// Only user drags arm the hint; programmatic animateTo must not flash it.
final isUserDrag = switch (notification) {
ScrollStartNotification(:final dragDetails) => dragDetails != null,
ScrollUpdateNotification(:final dragDetails) => dragDetails != null,
_ => false,
};
if (isUserDrag) {
_hideTimer?.cancel();
if (!_shown) {
setState(() => _shown = true);
}
}
return false;
}
double _thumbCenter(
ScrollMetrics metrics,
double height,
@@ -159,9 +443,11 @@ class _FloatingScrollbarState extends State<FloatingScrollbar> {
1.0,
)
: 1.0;
// Material scrollbar defaults: 48px min thumb length, no main-axis
// margin. Reversed lists place the thumb at (1 - fraction).
final thumbExtent = max(48.0, track * fractionVisible).clamp(0.0, track);
// Reversed lists place the thumb at (1 - fraction).
final thumbExtent = max(
_minThumbLength,
track * fractionVisible,
).clamp(0.0, track);
final thumbFraction = axisDirectionIsReversed(metrics.axisDirection)
? 1 - fraction
: fraction;
@@ -177,78 +463,92 @@ class _FloatingScrollbarState extends State<FloatingScrollbar> {
final barPadding = behavior is BaseScrollBehavior
? behavior.scrollbarPadding
: EdgeInsets.zero;
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: LayoutBuilder(
builder: (context, constraints) {
String? label;
var top = 0.0;
if (_shown && widget.controller.hasClients) {
final metrics = widget.controller.position;
if (metrics.maxScrollExtent > metrics.minScrollExtent) {
final scrollableExtent =
metrics.maxScrollExtent - metrics.minScrollExtent;
final fraction =
((metrics.pixels - metrics.minScrollExtent) /
scrollableExtent)
.clamp(0.0, 1.0);
// Continuous clamp: the pill stays clear of the FAB zone at any height.
final half = _pillHeight / 2;
final limit = (constraints.maxHeight - bottomInset - _fabZoneGap)
.clamp(0.0, double.infinity);
top = _thumbCenter(
metrics,
constraints.maxHeight,
MediaQuery.paddingOf(context) + barPadding,
).clamp(half, max(half, limit - half));
label = widget.hintBuilder(fraction);
}
// A finger on the thumb covers the bar's edge, so the hint keeps clear.
final hintGap = isTouchPlatform(Theme.of(context).platform) ? 40.0 : 20.0;
return LayoutBuilder(
builder: (context, constraints) {
String? label;
var top = 0.0;
if (_hintMounted && widget.controller.hasClients) {
final metrics = widget.controller.position;
if (metrics.maxScrollExtent > metrics.minScrollExtent) {
final scrollableExtent =
metrics.maxScrollExtent - metrics.minScrollExtent;
final fraction =
((metrics.pixels - metrics.minScrollExtent) / scrollableExtent)
.clamp(0.0, 1.0);
// Continuous clamp: the pill stays clear of the FAB zone at any height.
final half = _pillHeight / 2;
final limit = (constraints.maxHeight - bottomInset - _fabZoneGap)
.clamp(0.0, double.infinity);
top = _thumbCenter(
metrics,
constraints.maxHeight,
MediaQuery.paddingOf(context) + barPadding,
).clamp(half, max(half, limit - half));
label = widget.hintBuilder(fraction);
}
return Stack(
children: [
// This is the list's only bar; the ambient behavior adds none.
CommonScrollBar(
controller: widget.controller,
padding: barPadding,
child: ScrollConfiguration(
behavior: const HiddenBarScrollBehavior(),
child: widget.child,
),
),
if (label != null)
Positioned(
right: 12,
top: top,
child: FractionalTranslation(
translation: const Offset(0, -0.5),
child: IgnorePointer(
child: _MeasureSize(
onChanged: (size) {
if (mounted && size.height != _pillHeight) {
setState(() => _pillHeight = size.height);
}
},
child: _ScrollbarHintPill(label: label),
),
}
return Stack(
children: [
CommonScrollBar(
controller: widget.controller,
thumbVisibility: widget.thumbVisibility,
padding: barPadding,
onScrub: _handleScrub,
child: widget.child,
),
// Outlives the label, so the fade-out still ends and unmounts it.
if (_hintMounted)
Positioned(
right: hintGap,
top: top,
child: FractionalTranslation(
translation: const Offset(0, -0.5),
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _hintVisible ? 1 : 0,
duration: const Duration(milliseconds: 150),
onEnd: () {
if (!_hintVisible) {
setState(() => _hintMounted = false);
}
},
child: label == null
? const SizedBox.shrink()
: _MeasureSize(
onChanged: (size) {
if (mounted && size.height != _pillHeight) {
setState(() => _pillHeight = size.height);
}
},
child: _ScrollbarHintPill(
label: label,
scrubRate: _scrubRate,
),
),
),
),
),
],
);
},
),
),
],
);
},
);
}
}
class _ScrollbarHintPill extends StatelessWidget {
final String label;
final double? scrubRate;
const _ScrollbarHintPill({required this.label});
const _ScrollbarHintPill({required this.label, required this.scrubRate});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final style = context.textTheme.labelMedium;
final rate = scrubRate;
return DecoratedBox(
key: const ValueKey('scrollbarHintPill'),
decoration: ShapeDecoration(
@@ -259,11 +559,22 @@ class _ScrollbarHintPill extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: style?.copyWith(color: colorScheme.onSurfaceVariant),
),
if (rate != null && rate < 1) ...[
const SizedBox(width: 6),
Text(
'×1/${(1 / rate).round()}',
key: const ValueKey('scrollbarScrubRate'),
style: style?.copyWith(color: colorScheme.primary),
),
],
],
),
),
);
+68
View File
@@ -0,0 +1,68 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:material_ui/material_ui.dart';
/// Below this many items a sheet is short enough to scan without a search.
const sheetSearchMinItemCount = 10;
class SearchField extends StatefulWidget {
final ValueChanged<String> onChanged;
final TextEditingController? controller;
final FocusNode? focusNode;
const SearchField({
super.key,
required this.onChanged,
this.controller,
this.focusNode,
});
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> {
TextEditingController? _ownedController;
TextEditingController get _controller =>
widget.controller ?? (_ownedController ??= TextEditingController());
@override
void dispose() {
_ownedController?.dispose();
super.dispose();
}
void _handleClear() {
_controller.clear();
widget.onChanged('');
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (_, value, _) {
return TextField(
controller: _controller,
focusNode: widget.focusNode,
textInputAction: TextInputAction.search,
inputFormatters: TextInputLimits.limit(TextInputLimits.search),
onChanged: widget.onChanged,
decoration: InputDecoration(
hintText: appLocalizations.search,
prefixIcon: const GlyphIcon(AppGlyphs.search, size: 20),
suffixIcon: value.text.isEmpty
? null
: IconButton(
tooltip: appLocalizations.clearSearch,
onPressed: _handleClear,
icon: const GlyphIcon(AppGlyphs.close, size: 20),
),
),
);
},
);
}
}
+2
View File
@@ -33,9 +33,11 @@ export 'paged_sheet.dart';
export 'palette.dart';
export 'pop_scope.dart';
export 'popup.dart';
export 'record.dart';
export 'route_motion_hold.dart';
export 'scaffold.dart';
export 'scroll.dart';
export 'search_field.dart';
export 'setting.dart';
export 'sheet.dart';
export 'sheet_header.dart';
+2 -2
View File
@@ -922,10 +922,10 @@ packages:
dependency: "direct main"
description:
name: mobile_scanner
sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce
sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0"
url: "https://pub.dev"
source: hosted
version: "7.4.0"
version: "7.4.2"
mockito:
dependency: transitive
description:
+1 -1
View File
@@ -31,7 +31,7 @@ dependencies:
ref: e930ce65c5804343103447e01d39fccabedc8681
json_annotation: ^4.12.0
file_picker: ^12.0.0
mobile_scanner: ^7.4.0
mobile_scanner: ^7.4.2
app_links: ^7.1.2
win32_registry: ^3.0.3
tray:
+115
View File
@@ -338,4 +338,119 @@ void main() {
expect(group.getCurrentSelectedName(''), '');
});
});
group('computeHideTimeout', () {
const defaultTestUrl = 'https://default.test';
List<Group> hide({
required List<Group> groups,
required DelayMap delayMap,
Map<String, String> selectedMap = const {},
}) {
return computeHideTimeout(
groups: groups,
allGroups: groups,
delayMap: delayMap,
selectedMap: selectedMap,
defaultTestUrl: defaultTestUrl,
);
}
const group = Group(
name: 'sel',
type: GroupType.Selector,
all: [
Proxy(name: 'fast', type: 'ss'),
Proxy(name: 'slow', type: 'ss'),
Proxy(name: 'untested', type: 'ss'),
],
);
test('drops the proxies whose last probe timed out', () {
final groups = hide(
groups: [group],
delayMap: {
defaultTestUrl: {'fast': 120, 'slow': -1},
},
);
expect(groups.single.all.map((proxy) => proxy.name), [
'fast',
'untested',
]);
});
test('keeps a timed-out proxy while it is the selected one', () {
final groups = hide(
groups: [group],
delayMap: {
defaultTestUrl: {'slow': -1},
},
selectedMap: {'sel': 'slow'},
);
expect(groups.single.all.map((proxy) => proxy.name), [
'fast',
'slow',
'untested',
]);
});
test('keeps built-in adapters a delay probe cannot reach', () {
const withBuiltins = Group(
name: 'sel',
type: GroupType.Selector,
all: [
Proxy(name: 'slow', type: 'ss'),
Proxy(name: 'REJECT', type: 'Reject'),
Proxy(name: 'PASS', type: 'Pass'),
Proxy(name: 'blocked', type: 'Selector'),
],
);
const blocked = Group(
name: 'blocked',
type: GroupType.Selector,
all: [Proxy(name: 'REJECT', type: 'Reject')],
);
final groups = hide(
groups: [withBuiltins, blocked],
delayMap: {
defaultTestUrl: {'slow': -1, 'REJECT': -1, 'PASS': -1},
},
);
expect(groups.first.all.map((proxy) => proxy.name), [
'REJECT',
'PASS',
'blocked',
]);
});
test("reads a group's own test url", () {
const withTestUrl = Group(
name: 'sel',
type: GroupType.Selector,
testUrl: 'https://group.test',
all: [
Proxy(name: 'fast', type: 'ss'),
Proxy(name: 'slow', type: 'ss'),
],
);
expect(
hide(
groups: [withTestUrl],
delayMap: {
defaultTestUrl: {'slow': -1},
},
).single.all,
hasLength(2),
);
expect(
hide(
groups: [withTestUrl],
delayMap: {
'https://group.test': {'slow': -1},
},
).single.all.map((proxy) => proxy.name),
['fast'],
);
});
});
}
+11
View File
@@ -29,6 +29,17 @@ void main() {
expect(dateTime.showTime, ' 14:03:09');
});
test('formats offset timestamps in the system time zone', () {
final parsed = DateTime.parse('2026-05-27T14:03:09+08:00');
final local = DateTime.fromMillisecondsSinceEpoch(
parsed.millisecondsSinceEpoch,
);
expect(parsed.isUtc, isTrue);
expect(parsed.showFull, local.toString().substring(0, 19));
expect(parsed.show, local.toString().substring(0, 10));
});
testWidgets(
'getLastUpdateTimeDesc returns unknown for epoch or year <= 1970',
(tester) async {
+156 -2
View File
@@ -1,8 +1,100 @@
import 'dart:ui';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:test/test.dart';
void main() {
group('DnsQueriesState.list', () {
setUpAll(() => AppLocalizations.load(const Locale('en')));
DnsQuery dnsQuery(
String domain, {
String type = 'A',
DnsQueryInitiator? initiator,
bool cached = false,
List<String> answers = const [],
String rcode = 'NOERROR',
}) => DnsQuery(
domain: domain,
type: type,
initiator: initiator,
cached: cached,
answers: answers,
rcode: rcode,
time: DateTime.utc(2026),
);
final state = DnsQueriesState(
dnsQueries: [
dnsQuery('a.test', initiator: DnsQueryInitiator.app),
dnsQuery('b.test', type: 'AAAA', answers: ['2001:DB8::1']),
dnsQuery('c.test', rcode: 'NXDOMAIN'),
dnsQuery('d.test', initiator: DnsQueryInitiator.direct, cached: true),
],
);
List<String> domains(DnsQueriesState state) =>
state.list.map((dnsQuery) => dnsQuery.domain).toList();
test('keywords match the chips a query shows', () {
expect(domains(state.copyWith(keywords: ['App'])), ['a.test']);
expect(domains(state.copyWith(keywords: ['AAAA'])), ['b.test']);
expect(domains(state.copyWith(keywords: ['NXDOMAIN'])), ['c.test']);
expect(domains(state.copyWith(keywords: ['NOERROR'])), isEmpty);
expect(domains(state.copyWith(keywords: ['Direct'])), ['d.test']);
expect(domains(state.copyWith(keywords: ['Cache'])), ['d.test']);
});
test('the initiator and a cache hit are chips', () {
expect(state.dnsQueries[3].tags, ['A', 'Direct', 'Cache']);
});
test('an unknown initiator and a clean rcode are not chips', () {
expect(state.dnsQueries[1].tags, ['AAAA']);
});
test('query matches the domain or an answer, case-insensitively', () {
expect(domains(state.copyWith(query: 'C.TEST')), ['c.test']);
expect(domains(state.copyWith(query: '2001:db8')), ['b.test']);
});
test('query matches the upstream, the error and the chips', () {
final state = DnsQueriesState(
dnsQueries: [
DnsQuery(
domain: 'a.test',
type: 'A',
upstream: 'https://dns.example/dns-query',
time: DateTime.utc(2026),
),
DnsQuery(
domain: 'b.test',
type: 'AAAA',
error: 'i/o timeout',
time: DateTime.utc(2026),
),
],
);
expect(domains(state.copyWith(query: 'dns.example')), ['a.test']);
expect(domains(state.copyWith(query: 'timeout')), ['b.test']);
expect(domains(state.copyWith(query: 'aaaa')), ['b.test']);
});
test('every query term has to match', () {
expect(domains(state.copyWith(query: 'test app')), ['a.test']);
expect(domains(state.copyWith(query: 'a.test aaaa')), isEmpty);
});
test('is searching once a query or a keyword is set', () {
expect(state.isSearching, isFalse);
expect(state.copyWith(query: ' ').isSearching, isFalse);
expect(state.copyWith(query: 'a').isSearching, isTrue);
expect(state.copyWith(keywords: ['A']).isSearching, isTrue);
});
});
group('LogsState.list', () {
Log log(LogLevel level, String payload) =>
Log(logLevel: level, payload: payload, dateTime: '2024-01-01');
@@ -65,6 +157,21 @@ void main() {
);
expect(state.list, isEmpty);
});
test('query terms match in any order and across fields', () {
final state = LogsState(
logs: [
log(LogLevel.error, 'dial tcp timeout'),
log(LogLevel.info, 'dial tcp ok'),
],
query: 'TIMEOUT dial',
);
expect(state.list.single.payload, 'dial tcp timeout');
expect(
state.copyWith(query: 'error dial').list.single.payload,
'dial tcp timeout',
);
});
});
group('TrackerInfosState.list', () {
@@ -84,13 +191,15 @@ void main() {
String id, {
List<String> chains = const ['proxy-a'],
Metadata? metadata,
String rule = 'MATCH',
String rulePayload = '',
}) => TrackerInfo(
id: id,
start: DateTime(2024),
metadata: metadata ?? meta(),
chains: chains,
rule: 'MATCH',
rulePayload: '',
rule: rule,
rulePayload: rulePayload,
);
test('returns all when no keywords and empty query', () {
@@ -169,5 +278,50 @@ void main() {
expect(state.list.length, 1);
expect(state.list[0].id, '2');
});
test('query matches the rule and its payload', () {
final state = TrackerInfosState(
trackerInfos: [
tracker('1'),
tracker('2', rule: 'DomainSuffix', rulePayload: 'googleapis.com'),
],
);
expect(state.copyWith(query: 'domainsuffix').list.single.id, '2');
expect(state.copyWith(query: 'googleapis').list.single.id, '2');
});
test('query matches the source address and the process path', () {
final state = TrackerInfosState(
trackerInfos: [
tracker('1'),
tracker(
'2',
metadata: const Metadata(
sourceIP: '198.18.0.1',
sourcePort: '53211',
processPath: '/usr/bin/curl',
),
),
],
);
expect(state.copyWith(query: '198.18.0.1').list.single.id, '2');
expect(state.copyWith(query: '53211').list.single.id, '2');
expect(state.copyWith(query: '/usr/bin').list.single.id, '2');
});
test('every query term has to match', () {
final state = TrackerInfosState(
trackerInfos: [
tracker('1', metadata: meta(host: 'github.com')),
tracker(
'2',
chains: ['proxy-b'],
metadata: meta(host: 'github.com'),
),
],
query: 'GitHub proxy-b',
);
expect(state.list.single.id, '2');
});
});
}
+47
View File
@@ -106,4 +106,51 @@ void main() {
expect(linkManager.subscription, isNull);
expect(received, isEmpty);
});
group('profileUrlFromQrCodes', () {
test('accepts a bare url with surrounding whitespace', () {
expect(
profileUrlFromQrCodes([' https://example.com/sub?token=1\n']),
'https://example.com/sub?token=1',
);
});
test('unwraps the url of an install-config link for every scheme', () {
for (final scheme in ['clash', 'clashmeta', 'flclash']) {
expect(
profileUrlFromQrCodes([
'$scheme://install-config?url=https%3A%2F%2Fexample.com%2Fa.yaml',
]),
'https://example.com/a.yaml',
);
}
});
test('rejects codes that carry no profile url', () {
expect(
profileUrlFromQrCodes([
null,
'',
'plain text',
'vmess://eyJhZGQiOiIxLjIuMy40In0=',
'https://',
'clash://install-config?url=not-a-url',
'clash://open-profile?url=https://example.com/a.yaml',
'other://install-config?url=https://example.com/a.yaml',
]),
isNull,
);
});
test('returns the first code that carries a profile url', () {
expect(
profileUrlFromQrCodes([
'plain text',
'flclash://install-config?url=https://example.com/first.yaml',
'https://example.com/second.yaml',
]),
'https://example.com/first.yaml',
);
});
});
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:fl_clash/common/search.dart';
import 'package:test/test.dart';
void main() {
group('SearchQuery', () {
test('blank text is an empty query that matches everything', () {
final query = SearchQuery(' \t ');
expect(query.isEmpty, isTrue);
expect(query.matches(const []), isTrue);
});
test('splits on whitespace and lowers the case', () {
expect(SearchQuery(' HK Node\t1 ').terms, ['hk', 'node', '1']);
});
test('matches a field regardless of case', () {
expect(SearchQuery('google').matches(['www.Google.com']), isTrue);
expect(SearchQuery('GOOGLE').matches(['www.google.com']), isTrue);
});
test('every term has to match, each in any field', () {
final fields = ['example.com', 'DIRECT', 'chrome'];
expect(SearchQuery('example chrome').matches(fields), isTrue);
expect(SearchQuery('chrome direct').matches(fields), isTrue);
expect(SearchQuery('example firefox').matches(fields), isFalse);
});
test('a term does not span two fields', () {
expect(SearchQuery('comdirect').matches(['a.com', 'direct']), isFalse);
});
test('ignores null fields', () {
expect(SearchQuery('tcp').matches([null, 'tcp']), isTrue);
expect(SearchQuery('tcp').matches([null]), isFalse);
});
});
group('whereMatches', () {
final items = ['Alpha', 'Beta', 'Gamma'];
test('keeps every item for an empty query', () {
expect(items.whereMatches(SearchQuery(''), (item) => [item]), items);
});
test('keeps the order of the items that match', () {
expect(
items.whereMatches(SearchQuery('a'), (item) => [item]).toList(),
items,
);
expect(items.whereMatches(SearchQuery('MA'), (item) => [item]).toList(), [
'Gamma',
]);
});
test('builds an object\'s text once across searches', () {
final objects = [for (final item in items) _Named(item)];
final texts = Expando<String>();
var built = 0;
List<String> search(String text) => objects
.whereMatches(SearchQuery(text), (object) {
built++;
return [object.name];
}, texts: texts)
.map((object) => object.name)
.toList();
expect(search('a'), items);
expect(search('ma'), ['Gamma']);
expect(built, objects.length);
});
test('searches values an expando cannot hold without caching', () {
final texts = Expando<String>();
expect(
items
.whereMatches(SearchQuery('beta'), (item) => [item], texts: texts)
.toList(),
['Beta'],
);
});
});
}
class _Named {
final String name;
_Named(this.name);
}
@@ -0,0 +1,91 @@
import 'package:fl_clash/features/connection/tracker_speed_ranker.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
TrackerInfo _tracker(
String id, {
int upload = 0,
int download = 0,
DateTime? start,
}) {
return TrackerInfo(
id: id,
upload: upload,
download: download,
start: start ?? DateTime.utc(2026),
metadata: const Metadata(),
chains: const [],
rule: 'MATCH',
rulePayload: '',
);
}
void main() {
final t0 = DateTime.utc(2026, 1, 1, 12);
DateTime at(double seconds) =>
t0.add(Duration(milliseconds: (seconds * 1000).round()));
List<String> ids(List<TrackerInfo> trackerInfos) =>
trackerInfos.map((trackerInfo) => trackerInfo.id).toList();
test('reports no speed on the first sample and ranks by traffic', () {
final ranked = TrackerSpeedRanker().rank([
_tracker('a', download: 100),
_tracker('b', download: 900),
], t0);
expect(ids(ranked), ['b', 'a']);
expect(ranked.map((trackerInfo) => trackerInfo.downloadSpeed), [0, 0]);
});
test('derives speed from the delta over the elapsed time', () {
final ranker = TrackerSpeedRanker();
ranker.rank([_tracker('a', upload: 100, download: 1000)], t0);
final ranked = ranker.rank([
_tracker('a', upload: 1100, download: 5000),
], at(2));
expect(ranked.single.uploadSpeed, 500);
expect(ranked.single.downloadSpeed, 2000);
});
test('puts an active connection above a larger idle one', () {
final ranker = TrackerSpeedRanker();
ranker.rank([_tracker('idle', download: 1 << 30), _tracker('active')], t0);
final ranked = ranker.rank([
_tracker('idle', download: 1 << 30),
_tracker('active', download: 4096),
], at(1));
expect(ids(ranked), ['active', 'idle']);
});
test('keeps the order of connections with similar speeds', () {
final ranker = TrackerSpeedRanker();
ranker.rank([_tracker('a'), _tracker('b')], t0);
final first = ranker.rank([
_tracker('a', download: 5000),
_tracker('b', download: 4500),
], at(1));
expect(ids(first), ['a', 'b']);
final second = ranker.rank([
_tracker('a', download: 10000),
_tracker('b', download: 10200),
], at(2));
expect(ids(second), ['a', 'b']);
expect(second.last.downloadSpeed, greaterThan(second.first.downloadSpeed!));
});
test('ignores a sample taken too soon after the previous one', () {
final ranker = TrackerSpeedRanker();
ranker.rank([_tracker('a')], t0);
ranker.rank([_tracker('a', download: 1000)], at(1));
final ranked = ranker.rank([_tracker('a', download: 1100)], at(1.05));
expect(ranked.single.downloadSpeed, 1000);
});
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
final _scrollbarPaint = find.byWidgetPredicate(
(widget) =>
widget is CustomPaint && widget.foregroundPainter is ScrollbarPainter,
);
ScrollbarPainter scrollbarPainter(WidgetTester tester) =>
tester.widget<CustomPaint>(_scrollbarPaint.first).foregroundPainter!
as ScrollbarPainter;
/// Nudges [scrollable] so its faded-out bar shows, and returns the global
/// center of the thumb, which only takes a pointer while visible.
Future<Offset> revealScrollbarThumb(
WidgetTester tester, {
Finder? scrollable,
}) async {
final position = tester
.state<ScrollableState>(scrollable ?? find.byType(Scrollable).first)
.position;
final pixels = position.pixels;
position.jumpTo(pixels > position.minScrollExtent ? pixels - 1 : pixels + 1);
position.jumpTo(pixels);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final painter = scrollbarPainter(tester);
final box = tester.renderObject<RenderBox>(_scrollbarPaint.first);
final x = box.size.width - 5;
double? top;
double? bottom;
for (var y = 0.0; y < box.size.height; y++) {
if (painter.hitTestOnlyThumbInteractive(
Offset(x, y),
PointerDeviceKind.mouse,
)) {
top ??= y;
bottom = y;
}
}
expect(top, isNotNull, reason: 'no visible scrollbar thumb');
return box.localToGlobal(Offset(x, (top! + bottom!) / 2));
}
+80 -56
View File
@@ -1,9 +1,8 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
@@ -62,54 +61,6 @@ void main() {
verifyNever(() => file.length());
verifyNever(() => file.lastModified());
});
testWidgets('shows unknown while preserving file size', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
home: Builder(
builder: (context) {
return Text(const FileInfo(size: 1024).getDesc(context));
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('1KB · Unknown'), findsOneWidget);
});
testWidgets('shows relative time for valid last modified time', (
tester,
) async {
final lastModified = DateTime.now().subtract(const Duration(days: 2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
home: Builder(
builder: (context) {
return Text(
FileInfo(
size: 1024,
lastModified: lastModified,
).getDesc(context),
);
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('1KB · 2 days ago'), findsOneWidget);
});
});
group('PackagesExt', () {
@@ -165,7 +116,7 @@ void main() {
});
group('TrackerInfoExt', () {
test('builds destination description and process text', () {
test('builds destination title and process text', () {
final trackerInfo = TrackerInfo(
id: '1',
start: DateTime(2026),
@@ -182,17 +133,16 @@ void main() {
rulePayload: '',
);
expect(trackerInfo.desc, 'tcp://example.com/1.1.1.1:443');
expect(trackerInfo.title, 'example.com');
expect(trackerInfo.progressText, 'Browser(501)');
});
});
group('TrafficExt', () {
test('formats speed, description, tray title, and total speed', () {
test('formats speed, tray title, and total speed', () {
const traffic = Traffic(up: 1024, down: 2048);
expect(traffic.speedText, '↑ 1KB/s ↓ 2KB/s');
expect(traffic.desc, '1KB ↑ 2KB ↓');
expect(traffic.trayTitle, '1 KB/s\n2 KB/s');
expect(traffic.speed, 3072);
});
@@ -217,6 +167,39 @@ void main() {
});
});
group('ScriptsExt naming', () {
Script script(int id, String label) =>
Script(id: id, label: label, lastUpdateTime: DateTime(2026));
test('hasLabel ignores the script being renamed', () {
final scripts = [script(1, 'a'), script(2, 'b')];
expect(scripts.hasLabel('a'), isTrue);
expect(scripts.hasLabel('a', except: scripts[0]), isFalse);
expect(scripts.hasLabel('a', except: scripts[1]), isTrue);
expect(scripts.hasLabel('c'), isFalse);
});
test('uniqueLabel trims, falls back and numbers duplicates', () {
final scripts = [script(1, 'remote'), script(2, 'remote(1)')];
expect(scripts.uniqueLabel(' local ', fallback: 'Script'), 'local');
expect(scripts.uniqueLabel('remote', fallback: 'Script'), 'remote(2)');
expect(scripts.uniqueLabel(' ', fallback: 'Script'), 'Script');
expect(
[script(3, 'Script')].uniqueLabel('', fallback: 'Script'),
'Script(1)',
);
});
test('uniqueLabel keeps the numbered label within the name limit', () {
final long = 'x' * 100;
final scripts = [script(1, 'x' * TextInputLimits.name)];
final label = scripts.uniqueLabel(long, fallback: 'Script');
expect(label.length, lessThanOrEqualTo(TextInputLimits.name));
expect(label, endsWith('(1)'));
expect(scripts.hasLabel(label), isFalse);
});
});
group('IpInfo parsers', () {
test('parse supported response shapes', () {
expect(
@@ -224,13 +207,43 @@ void main() {
const IpInfo(ip: '1.1.1.1', countryCode: 'US'),
);
expect(
IpInfo.fromMyIpJson({'ip': '2.2.2.2', 'cc': 'JP'}),
IpInfo.fromIdentMeJson({'ip': '2.2.2.2', 'cc': 'JP'}),
const IpInfo(ip: '2.2.2.2', countryCode: 'JP'),
);
expect(
IpInfo.fromIpAPIJson({'query': '3.3.3.3', 'countryCode': 'CN'}),
const IpInfo(ip: '3.3.3.3', countryCode: 'CN'),
);
expect(
IpInfo.fromGeoJsJson({'ip': '4.4.4.4', 'country_code': 'DE'}),
const IpInfo(ip: '4.4.4.4', countryCode: 'DE'),
);
expect(
IpInfo.fromCountryIsJson({'ip': '5.5.5.5', 'country': 'FR'}),
const IpInfo(ip: '5.5.5.5', countryCode: 'FR'),
);
});
test('parse the ipquery.io country from its location object', () {
expect(
IpInfo.fromIpQueryJson({
'ip': '203.0.113.9',
'isp': {'asn': 'AS64500', 'org': '', 'isp': 'Example'},
'location': {'country': 'United Kingdom', 'country_code': 'GB'},
'risk': {'is_datacenter': false},
}),
const IpInfo(ip: '203.0.113.9', countryCode: 'GB'),
);
});
test('parse the Cloudflare trace key-value text', () {
expect(
IpInfo.fromCloudflareTrace(
'fl=0f0\nh=www.cloudflare.com\nip=203.0.113.9\nts=1757800000.1\n'
'visit_scheme=https\ncolo=LHR\nhttp=http/2\nloc=GB\ntls=TLSv1.3\n',
),
const IpInfo(ip: '203.0.113.9', countryCode: 'GB'),
);
});
test('throw FormatException for unsupported response shapes', () {
@@ -239,7 +252,18 @@ void main() {
throwsFormatException,
);
expect(
() => IpInfo.fromIpApiCoJson({'ip': '1.1.1.1'}),
() => IpInfo.fromGeoJsJson({'ip': '1.1.1.1'}),
throwsFormatException,
);
expect(
() => IpInfo.fromIpQueryJson({
'ip': '1.1.1.1',
'location': <String, dynamic>{},
}),
throwsFormatException,
);
expect(
() => IpInfo.fromCloudflareTrace('ip=1.1.1.1\ncolo=LHR\n'),
throwsFormatException,
);
});
+454 -27
View File
@@ -1,12 +1,17 @@
import 'dart:async';
import 'package:fl_clash/common/app_localizations.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/pages/scan.dart';
import 'package:fl_clash/widgets/activate_box.dart';
import 'package:fl_clash/widgets/null_status.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
class _FakeScannerPlatform extends MobileScannerPlatform {
@@ -19,9 +24,17 @@ class _FakeScannerPlatform extends MobileScannerPlatform {
int startCalls = 0;
int stopCalls = 0;
int disposeCalls = 0;
int toggleTorchCalls = 0;
TorchState initialTorchState = TorchState.off;
MobileScannerException? startError;
Completer<void>? startGate;
void emit(BarcodeCapture capture) => _barcodes.add(capture);
void emitError(Object error) => _barcodes.addError(error);
void emitTorch(TorchState state) => _torch.add(state);
@override
Stream<BarcodeCapture?> get barcodesStream => _barcodes.stream;
@@ -37,13 +50,23 @@ class _FakeScannerPlatform extends MobileScannerPlatform {
@override
Future<MobileScannerViewAttributes> start(StartOptions startOptions) async {
startCalls++;
return const MobileScannerViewAttributes(
await startGate?.future;
final error = startError;
if (error != null) {
throw error;
}
return MobileScannerViewAttributes(
cameraDirection: CameraFacing.back,
currentTorchMode: TorchState.off,
size: Size(100, 100),
currentTorchMode: initialTorchState,
size: const Size(100, 100),
);
}
@override
Future<void> toggleTorch() async {
toggleTorchCalls++;
}
@override
Future<void> stop() async {
stopCalls++;
@@ -73,6 +96,15 @@ BarcodeCapture _capture({required BarcodeType type, String? rawValue}) {
);
}
BarcodeCapture _textCapture(List<String> rawValues) {
return BarcodeCapture(
barcodes: [
for (final rawValue in rawValues)
Barcode(type: BarcodeType.text, rawValue: rawValue),
],
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -84,11 +116,19 @@ void main() {
addTearDown(platform.close);
});
/// The scan effect repeats for as long as the page is mounted, so
/// `pumpAndSettle` only returns once the page has been popped or unmounted.
Future<void> pumpFrames(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(seconds: 1));
}
/// Pushes the page onto a route so `Navigator.pop` from `_handleBarcode` has
/// something to pop, and reports what the page popped with.
Future<void> pumpScanPage(
WidgetTester tester, {
required void Function(String? result) onPopped,
VoidCallback? onBelowPopped,
}) async {
late BuildContext hostContext;
await tester.pumpWidget(
@@ -103,14 +143,33 @@ void main() {
),
),
);
final navigator = Navigator.of(hostContext);
if (onBelowPopped != null) {
unawaited(
navigator
.push<void>(
MaterialPageRoute<void>(builder: (_) => const Text('below')),
)
.then((_) => onBelowPopped()),
);
}
unawaited(
Navigator.of(hostContext)
navigator
.push<String>(
MaterialPageRoute<String>(builder: (_) => const ScanPage()),
)
.then(onPopped),
);
await tester.pumpAndSettle();
await pumpFrames(tester);
}
Future<void> sendLifecycle(WidgetTester tester, AppLifecycleState state) {
final messenger = tester.binding.defaultBinaryMessenger;
return messenger.handlePlatformMessage(
'flutter/lifecycle',
const StringCodec().encodeMessage(state.toString()),
(_) {},
);
}
group('ScanPage lifecycle', () {
@@ -180,41 +239,163 @@ void main() {
expect(result, 'https://sub.example/x');
});
testWidgets('a non-url barcode pops without a value', (tester) async {
String? result = 'unset';
testWidgets('an install-config link pops the url it carries', (
tester,
) async {
String? result;
await pumpScanPage(tester, onPopped: (value) => result = value);
platform.emit(
_textCapture([
'clash://install-config?url=https%3A%2F%2Fsub.example%2Fy',
]),
);
await tester.pumpAndSettle();
expect(result, 'https://sub.example/y');
});
testWidgets('the first profile link in a capture wins', (tester) async {
String? result;
await pumpScanPage(tester, onPopped: (value) => result = value);
platform.emit(
_textCapture(['WIFI:S:home;;', ' https://sub.example/z\n']),
);
await tester.pumpAndSettle();
expect(result, 'https://sub.example/z');
});
testWidgets('a code without a profile link keeps scanning with a hint', (
tester,
) async {
var popped = false;
await pumpScanPage(
tester,
onPopped: (value) {
result = value;
popped = true;
},
await pumpScanPage(tester, onPopped: (_) => popped = true);
platform.emit(_textCapture(['plain text']));
await pumpFrames(tester);
expect(popped, isFalse);
expect(
find.text(currentAppLocalizations.invalidProfileQrcode),
findsOneWidget,
);
platform.emit(_capture(type: BarcodeType.text, rawValue: 'plain text'));
await tester.pump(const Duration(seconds: 2));
expect(find.text(currentAppLocalizations.qrcodeDesc), findsOneWidget);
platform.emit(
_capture(type: BarcodeType.url, rawValue: 'https://sub.example/x'),
);
await tester.pumpAndSettle();
expect(popped, isTrue);
});
testWidgets('captures racing the exit transition pop only once', (
tester,
) async {
var popCount = 0;
await pumpScanPage(tester, onPopped: (_) => popCount++);
platform.emit(_textCapture(['https://a.example']));
platform.emit(_textCapture(['https://b.example']));
await tester.pump();
platform.emit(_textCapture(['https://c.example']));
await tester.pumpAndSettle();
expect(popCount, 1);
expect(find.byType(ScanPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('a capture after the page is closed pops nothing else', (
tester,
) async {
String? result = 'unset';
var belowPopped = false;
await pumpScanPage(
tester,
onPopped: (value) => result = value,
onBelowPopped: () => belowPopped = true,
);
await tester.tap(find.byTooltip(currentAppLocalizations.close));
await tester.pump();
platform.emit(
_capture(type: BarcodeType.url, rawValue: 'https://a.example'),
);
await tester.pumpAndSettle();
expect(result, isNull);
expect(belowPopped, isFalse);
expect(find.text('below'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a capture under a dialog leaves the dialog and the page', (
tester,
) async {
var popped = false;
await pumpScanPage(tester, onPopped: (_) => popped = true);
unawaited(
showDialog<void>(
context: tester.element(find.byType(ScanPage)),
builder: (_) => const Text('dialog'),
),
);
await pumpFrames(tester);
platform.emit(
_capture(type: BarcodeType.url, rawValue: 'https://a.example'),
);
await pumpFrames(tester);
expect(find.text('dialog'), findsOneWidget);
expect(find.byType(ScanPage), findsOneWidget);
expect(popped, isFalse);
});
testWidgets('a detection error is not surfaced as an uncaught error', (
tester,
) async {
await pumpScanPage(tester, onPopped: (_) {});
platform.emitError(const MobileScannerBarcodeException('boom'));
await pumpFrames(tester);
expect(find.byType(ScanPage), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('ScanPage app lifecycle', () {
Future<void> sendLifecycle(WidgetTester tester, AppLifecycleState state) {
final messenger = tester.binding.defaultBinaryMessenger;
return messenger.handlePlatformMessage(
'flutter/lifecycle',
const StringCodec().encodeMessage(state.toString()),
(_) {},
);
}
testWidgets('a resume during a pending start does not start again', (
tester,
) async {
final gate = Completer<void>();
platform.startGate = gate;
await pumpScanPage(tester, onPopped: (_) {});
expect(platform.startCalls, 1);
await sendLifecycle(tester, AppLifecycleState.inactive);
await sendLifecycle(tester, AppLifecycleState.resumed);
await pumpFrames(tester);
gate.complete();
await pumpFrames(tester);
expect(platform.startCalls, 1);
expect(tester.takeException(), isNull);
});
testWidgets('going inactive stops the camera', (tester) async {
await pumpScanPage(tester, onPopped: (_) {});
expect(platform.startCalls, 1);
await sendLifecycle(tester, AppLifecycleState.inactive);
await tester.pumpAndSettle();
await pumpFrames(tester);
expect(platform.stopCalls, 1);
});
@@ -226,9 +407,9 @@ void main() {
await pumpScanPage(tester, onPopped: (_) => popCount++);
await sendLifecycle(tester, AppLifecycleState.inactive);
await tester.pumpAndSettle();
await pumpFrames(tester);
await sendLifecycle(tester, AppLifecycleState.resumed);
await tester.pumpAndSettle();
await pumpFrames(tester);
expect(platform.startCalls, 2);
@@ -249,9 +430,9 @@ void main() {
// Without cancelling first, each resume adds another listener and the
// single capture below would be handled once per subscription.
await sendLifecycle(tester, AppLifecycleState.resumed);
await tester.pumpAndSettle();
await pumpFrames(tester);
await sendLifecycle(tester, AppLifecycleState.resumed);
await tester.pumpAndSettle();
await pumpFrames(tester);
platform.emit(
_capture(type: BarcodeType.url, rawValue: 'https://c.example'),
@@ -261,4 +442,250 @@ void main() {
expect(popCount, 1);
});
});
group('ScanPage camera errors', () {
testWidgets('a denied permission offers settings and stops the effect', (
tester,
) async {
platform.startError = const MobileScannerException(
errorCode: MobileScannerErrorCode.permissionDenied,
);
await pumpScanPage(tester, onPopped: (_) {});
await tester.pumpAndSettle();
expect(
find.text(currentAppLocalizations.cameraPermissionRequired),
findsOneWidget,
);
expect(find.text(currentAppLocalizations.settings), findsOneWidget);
expect(find.text(currentAppLocalizations.qrcodeDesc), findsNothing);
expect(find.byTooltip(currentAppLocalizations.pickFromAlbum), findsOne);
});
testWidgets('a denied permission is not retried on a plain resume', (
tester,
) async {
platform.startError = const MobileScannerException(
errorCode: MobileScannerErrorCode.permissionDenied,
);
await pumpScanPage(tester, onPopped: (_) {});
await sendLifecycle(tester, AppLifecycleState.inactive);
await sendLifecycle(tester, AppLifecycleState.resumed);
await pumpFrames(tester);
expect(platform.startCalls, 1);
});
testWidgets('returning from the settings action retries the camera', (
tester,
) async {
platform.startError = const MobileScannerException(
errorCode: MobileScannerErrorCode.permissionDenied,
);
await pumpScanPage(tester, onPopped: (_) {});
platform.startError = null;
await tester.tap(find.text(currentAppLocalizations.settings));
await sendLifecycle(tester, AppLifecycleState.inactive);
await sendLifecycle(tester, AppLifecycleState.resumed);
await pumpFrames(tester);
expect(platform.startCalls, 2);
expect(find.text(currentAppLocalizations.qrcodeDesc), findsOneWidget);
});
testWidgets('a failed start can be retried', (tester) async {
platform.startError = const MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
);
await pumpScanPage(tester, onPopped: (_) {});
expect(
find.text(currentAppLocalizations.cameraUnavailable),
findsOneWidget,
);
platform.startError = null;
await tester.tap(find.text(currentAppLocalizations.retry));
await pumpFrames(tester);
expect(platform.startCalls, 2);
expect(find.text(currentAppLocalizations.qrcodeDesc), findsOneWidget);
});
testWidgets('a device without a camera offers no retry', (tester) async {
platform.startError = const MobileScannerException(
errorCode: MobileScannerErrorCode.unsupported,
);
await pumpScanPage(tester, onPopped: (_) {});
expect(
find.text(currentAppLocalizations.cameraUnavailable),
findsOneWidget,
);
expect(find.text(currentAppLocalizations.retry), findsNothing);
});
});
group('ScanPage layout', () {
const viewports = [
(
name: 'a portrait phone',
size: Size(390, 844),
padding: FakeViewPadding(top: 47, bottom: 34),
textScale: 1.0,
),
(
name: 'a landscape phone',
size: Size(844, 390),
padding: FakeViewPadding(left: 47, right: 47, bottom: 21),
textScale: 1.0,
),
(
name: 'a short split-screen pane with large text',
size: Size(390, 420),
padding: FakeViewPadding(top: 24),
textScale: 2.0,
),
(
name: 'a landscape tablet',
size: Size(1280, 800),
padding: FakeViewPadding(top: 24, bottom: 48),
textScale: 1.0,
),
];
for (final viewport in viewports) {
testWidgets('${viewport.name} keeps every control clear of the window', (
tester,
) async {
tester.view
..physicalSize = viewport.size
..devicePixelRatio = 1
..padding = viewport.padding;
tester.platformDispatcher.textScaleFactorTestValue = viewport.textScale;
addTearDown(tester.view.reset);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await pumpScanPage(tester, onPopped: (_) {});
platform.emit(_textCapture(['plain text']));
await pumpFrames(tester);
final overlay = tester
.widgetList<CustomPaint>(find.byType(CustomPaint))
.map((paint) => paint.painter)
.whereType<ScannerOverlay>()
.single;
final rects = {
'scan window': overlay.scanWindow,
'hint': tester.getRect(
find.text(currentAppLocalizations.invalidProfileQrcode),
),
'torch': tester.getRect(
find.byTooltip(currentAppLocalizations.torch),
),
'close': tester.getRect(
find.byTooltip(currentAppLocalizations.close),
),
'album': tester.getRect(
find.byTooltip(currentAppLocalizations.pickFromAlbum),
),
};
final screen = Offset.zero & viewport.size;
expect(overlay.scanWindow.width, greaterThan(100));
expect(tester.takeException(), isNull);
for (final MapEntry(key: name, value: rect) in rects.entries) {
expect(screen.expandToInclude(rect), screen, reason: name);
for (final MapEntry(key: other, value: otherRect) in rects.entries) {
if (name.compareTo(other) < 0) {
expect(
rect.overlaps(otherRect),
isFalse,
reason: '$name overlaps $other',
);
}
}
}
});
}
testWidgets('a landscape phone keeps the permission prompt in one column', (
tester,
) async {
tester.view
..physicalSize = const Size(844, 390)
..devicePixelRatio = 1
..padding = const FakeViewPadding(left: 47, right: 47, bottom: 21);
addTearDown(tester.view.reset);
platform.startError = const MobileScannerException(
errorCode: MobileScannerErrorCode.permissionDenied,
);
await pumpScanPage(tester, onPopped: (_) {});
final status = tester.getRect(find.byType(NullStatus));
final label = tester.getRect(
find.text(currentAppLocalizations.cameraPermissionRequired),
);
final description = tester.getRect(
find.text(currentAppLocalizations.cameraPermissionDesc),
);
final settings = tester.getRect(
find.ancestor(
of: find.text(currentAppLocalizations.settings),
matching: find.bySubtype<FilledButton>(),
),
);
final close = tester.getRect(
find.byTooltip(currentAppLocalizations.close),
);
expect(tester.takeException(), isNull);
expect(status.top, greaterThanOrEqualTo(close.bottom));
expect(description.top, greaterThanOrEqualTo(label.bottom));
expect(settings.top, greaterThanOrEqualTo(description.bottom));
expect(settings.center.dx, moreOrLessEquals(label.center.dx, epsilon: 1));
});
});
group('ScanPage torch', () {
Finder torchAncestor<T extends Widget>() => find.ancestor(
of: find.byTooltip(currentAppLocalizations.torch),
matching: find.byType(T),
);
testWidgets('the torch button toggles the torch and follows its state', (
tester,
) async {
await pumpScanPage(tester, onPopped: (_) {});
await tester.tap(find.byTooltip(currentAppLocalizations.torch));
await pumpFrames(tester);
expect(platform.toggleTorchCalls, 1);
expect(find.byGlyph(AppGlyphs.torchOff), findsOneWidget);
platform.emitTorch(TorchState.on);
await pumpFrames(tester);
expect(find.byGlyph(AppGlyphs.torch), findsOneWidget);
});
testWidgets('the torch button is hidden while the torch is unavailable', (
tester,
) async {
platform.initialTorchState = TorchState.unavailable;
await pumpScanPage(tester, onPopped: (_) {});
expect(
tester.widget<ActivateBox>(torchAncestor<ActivateBox>()).active,
isFalse,
);
expect(
tester
.widget<AnimatedOpacity>(torchAncestor<AnimatedOpacity>())
.opacity,
0,
);
});
});
}
+33 -108
View File
@@ -1,10 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/common/fixed.dart';
import 'package:fl_clash/common/request.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
@@ -321,19 +315,6 @@ void main() {
});
});
group('CheckIpNum provider', () {
test('default is 0', () {
expect(container.read(checkIpNumProvider), 0);
});
test('increment returns previous value and updates state', () {
final value = container.read(checkIpNumProvider.notifier).add();
expect(value, 0);
expect(container.read(checkIpNumProvider), 1);
});
});
group('SortNum provider', () {
test('increment returns previous value and updates state', () {
final value = container.read(sortNumProvider.notifier).add();
@@ -366,6 +347,39 @@ void main() {
expect(identical(container.read(delayDataSourceProvider), state), isTrue);
});
test('a reset of the state is not undone by the next delay', () {
const url = 'https://test.example';
final notifier = container.read(delayDataSourceProvider.notifier);
notifier.setDelay(const Delay(name: 'A', url: url, value: 10));
notifier.value = {};
notifier.setDelay(const Delay(name: 'B', url: url, value: 20));
expect(container.read(delayDataSourceProvider), {
url: {'B': 20},
});
});
test('applies a batch of delays as one state change', () {
const url = 'https://test.example';
final changes = <DelayMap>[];
container.listen(delayDataSourceProvider, (_, next) => changes.add(next));
container.read(delayDataSourceProvider.notifier).setDelays([
const Delay(name: 'A', url: url, value: 10),
const Delay(name: 'B', url: url, value: -1),
const Delay(name: 'A', url: url, value: 20),
const Delay(name: 'A', url: 'https://other.example', value: 30),
]);
expect(changes, [
{
url: {'A': 20, 'B': -1},
'https://other.example': {'A': 30},
},
]);
});
});
group('Loading provider', () {
@@ -473,93 +487,4 @@ void main() {
expect(container.read(coreStatusProvider), CoreStatus.disconnected);
});
});
group('NetworkDetection provider', () {
late HttpClientAdapter originalAdapter;
setUp(() {
originalAdapter = request.dio.httpClientAdapter;
});
tearDown(() {
request.dio.httpClientAdapter = originalAdapter;
});
test(
'ignores a canceled stale check after a newer check succeeds',
() async {
request.dio.httpClientAdapter = _DelayedCancelIpAdapter();
final container = ProviderContainer(
overrides: [
initProvider.overrideWithBuild((_, _) => true),
runTimeProvider.overrideWithBuild((_, _) => 1),
],
);
addTearDown(container.dispose);
final notifier = container.read(networkDetectionProvider.notifier);
notifier.startCheck();
await Future.delayed(commonDuration + const Duration(milliseconds: 50));
notifier.startCheck();
await Future.delayed(
commonDuration + const Duration(milliseconds: 120),
);
expect(container.read(networkDetectionProvider).ipInfo?.ip, '2.2.2.2');
expect(container.read(networkDetectionProvider).isLoading, false);
await Future.delayed(const Duration(milliseconds: 620));
expect(container.read(networkDetectionProvider).ipInfo?.ip, '2.2.2.2');
expect(container.read(networkDetectionProvider).isLoading, false);
},
);
});
}
class _DelayedCancelIpAdapter implements HttpClientAdapter {
static const _sourceCount = 7;
int _requestCount = 0;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) {
_requestCount++;
final batch = ((_requestCount - 1) ~/ _sourceCount) + 1;
if (batch == 1) {
final completer = Completer<ResponseBody>();
cancelFuture?.then((_) {
Timer(const Duration(milliseconds: 500), () {
if (completer.isCompleted) return;
completer.completeError(
DioException(
requestOptions: options,
type: DioExceptionType.cancel,
error: 'cancelled',
),
);
});
});
return completer.future;
}
return Future.delayed(
const Duration(milliseconds: 10),
() => ResponseBody.fromString(
'{"ip":"2.2.2.2","country_code":"US"}',
200,
headers: {
Headers.contentTypeHeader: ['application/json'],
},
),
);
}
@override
void close({bool force = false}) {}
}
+6 -3
View File
@@ -3,6 +3,7 @@ import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/status_manager.dart';
import 'package:fl_clash/providers/action.dart';
@@ -14,6 +15,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import '../helpers/glyph_finders.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
Future<ProviderContainer> _pumpGeoResourceAction(
@@ -93,8 +96,8 @@ void main() {
find.text(currentAppLocalizations.geoSkipped(GeoResource.MMDB.name)),
findsOneWidget,
);
expect(find.byIcon(Icons.check_circle_outline), findsNothing);
expect(find.byIcon(Icons.error_outline), findsNothing);
expect(find.byGlyph(AppGlyphs.checkCircle), findsNothing);
expect(find.byGlyph(AppGlyphs.error), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
@@ -211,7 +214,7 @@ void main() {
await tester.pump();
expect(find.text('download failed'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
expect(find.byGlyph(AppGlyphs.error), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
+220 -13
View File
@@ -83,6 +83,13 @@ void main() {
ProxiesAction actionOf(ProviderContainer container) =>
container.read(proxiesActionProvider.notifier);
ProviderContainer runningContainer() {
final container = buildContainer();
container.read(initProvider.notifier).value = true;
container.read(runTimeProvider.notifier).value = 1;
return container;
}
group('updateGroups', () {
test('publishes the groups derived from core proxy data', () async {
when(core.getProxies).thenAnswer(
@@ -186,17 +193,18 @@ void main() {
group('changeProxy', () {
setUp(() {
when(() => core.changeProxy(any())).thenAnswer((_) async => '');
when(
() => core.changeProxy(any()),
).thenAnswer((_) async => const ChangeProxyResult(changed: true));
when(core.closeConnections).thenAnswer((_) async => true);
when(core.resetConnections).thenAnswer((_) async => true);
});
test('closes connections and bumps the ip check when enabled', () async {
final container = buildContainer();
test('closes connections when enabled', () async {
final container = runningContainer();
container.read(appSettingProvider.notifier).value = const AppSettingProps(
closeConnections: true,
);
final before = container.read(checkIpNumProvider);
await actionOf(
container,
@@ -209,7 +217,6 @@ void main() {
).called(1);
verify(core.closeConnections).called(1);
verifyNever(core.resetConnections);
expect(container.read(checkIpNumProvider), before + 1);
});
test('resets connections instead when the setting is off', () async {
@@ -226,30 +233,31 @@ void main() {
verifyNever(core.closeConnections);
});
test('still bumps the ip check when the connection reset throws', () async {
test('a failing connection reset does not fail the switch', () async {
when(core.closeConnections).thenThrow(
const CoreMethodException(
code: 'transport_disconnected',
message: 'Core RPC client is closed',
),
);
final container = buildContainer();
final container = buildContainer(profile: _selectedProfile('HK-00'));
container.read(appSettingProvider.notifier).value = const AppSettingProps(
closeConnections: true,
);
final before = container.read(checkIpNumProvider);
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
expect(container.read(checkIpNumProvider), before + 1);
verify(core.closeConnections).called(1);
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-01',
});
});
test('skips the connection reset when the switch itself fails', () async {
when(() => core.changeProxy(any())).thenThrow(StateError('core down'));
final container = buildContainer();
final before = container.read(checkIpNumProvider);
final container = runningContainer();
await actionOf(
container,
@@ -257,7 +265,39 @@ void main() {
verifyNever(core.closeConnections);
verifyNever(core.resetConnections);
expect(container.read(checkIpNumProvider), before);
});
test(
'skips the connection reset when the Core reports no change',
() async {
when(
() => core.changeProxy(any()),
).thenAnswer((_) async => const ChangeProxyResult(changed: false));
final container = runningContainer();
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verifyNever(core.closeConnections);
verifyNever(core.resetConnections);
},
);
test('rolls the selection back when the Core answers a message', () async {
when(() => core.changeProxy(any())).thenAnswer(
(_) async => const ChangeProxyResult(message: 'proxy not exist'),
);
final container = buildContainer(profile: _selectedProfile('HK-00'));
await actionOf(
container,
).changeProxy(groupName: 'Proxy', proxyName: 'HK-01');
verifyNever(core.closeConnections);
expect(container.read(currentProfileProvider)?.selectedMap, {
'Proxy': 'HK-00',
});
});
test('commits the selection the Core accepted', () async {
@@ -314,7 +354,7 @@ void main() {
final observed = <bool>[];
when(() => core.asyncTestDelay(_testUrl, 'HK-01')).thenAnswer((_) async {
observed.add(
container.read(pendingDelayTestsProvider).contains(_delayKey),
container.read(pendingDelayTestsProvider).containsKey(_delayKey),
);
return const Delay(name: 'HK-01', url: _testUrl, value: 128);
});
@@ -388,6 +428,79 @@ void main() {
expect(container.read(pendingDelayTestsProvider), isEmpty);
});
test('lands a run of quick results in batches, not one per node', () async {
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) async => Delay(
name: invocation.positionalArguments[1] as String,
url: _testUrl,
value: 10,
),
);
final container = _delayContainer(buildContainer);
var delayChanges = 0;
var pendingChanges = 0;
container.listen(delayDataSourceProvider, (_, _) => delayChanges++);
container.listen(pendingDelayTestsProvider, (_, _) => pendingChanges++);
final proxies = List.generate(
1000,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
await actionOf(container).delayTest(proxies);
expect(
container.read(delayDataSourceProvider)[_testUrl],
hasLength(1000),
);
expect(container.read(pendingDelayTestsProvider), isEmpty);
expect(delayChanges, 1);
expect(pendingChanges, lessThanOrEqualTo(2));
});
test(
'marks only nodes the pool runs as running, the rest queued',
() async {
final answers = <String, Completer<Delay?>>{};
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) => answers
.putIfAbsent(
invocation.positionalArguments[1] as String,
Completer<Delay?>.new,
)
.future,
);
final container = _delayContainer(buildContainer);
final proxies = List.generate(
maxConcurrentDelayTests + 1,
(index) => Proxy(name: 'HK-$index', type: 'ss'),
);
DelayTestPhase? phaseOf(int index) => container.read(
pendingDelayTestsProvider,
)[delayTestKey(_testUrl, 'HK-$index')];
final run = actionOf(container).delayTest(proxies);
expect(phaseOf(0), DelayTestPhase.running);
expect(phaseOf(maxConcurrentDelayTests - 1), DelayTestPhase.running);
expect(phaseOf(maxConcurrentDelayTests), DelayTestPhase.queued);
answers['HK-0']!.complete(null);
await Future<void>.delayed(renderThrottleDuration * 2);
expect(phaseOf(0), isNull);
expect(phaseOf(maxConcurrentDelayTests), DelayTestPhase.running);
for (final answer in answers.values) {
if (!answer.isCompleted) {
answer.complete(null);
}
}
await run;
expect(container.read(pendingDelayTestsProvider), isEmpty);
},
);
test('probes a node that appears twice only once', () async {
when(() => core.asyncTestDelay(_testUrl, 'HK-01')).thenAnswer(
(_) async => const Delay(name: 'HK-01', url: _testUrl, value: 10),
@@ -399,6 +512,100 @@ void main() {
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
});
test('testing groups probes a shared node once per test URL', () async {
const otherUrl = 'http://other.test';
when(() => core.asyncTestDelay(any(), any())).thenAnswer(
(invocation) async => Delay(
url: invocation.positionalArguments[0] as String,
name: invocation.positionalArguments[1] as String,
value: 10,
),
);
final container = _delayContainer(buildContainer);
final before = container.read(sortNumProvider);
await actionOf(container).delayTestGroups([
_group('A', const [_proxy]),
_group('B', const [_proxy, Proxy(name: 'HK-02', type: 'ss')]),
const Group(
type: GroupType.URLTest,
name: 'C',
testUrl: otherUrl,
all: [_proxy],
),
]);
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
verify(() => core.asyncTestDelay(_testUrl, 'HK-02')).called(1);
verify(() => core.asyncTestDelay(otherUrl, 'HK-01')).called(1);
expect(container.read(sortNumProvider), before + 1);
});
test(
'testing a page group probes the nodes the timeout filter hides',
() async {
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) async => Delay(
name: invocation.positionalArguments[1] as String,
url: _testUrl,
value: 10,
),
);
final container = _delayContainer(buildContainer);
container
.read(groupsProvider.notifier)
.update(
(_) => [
_group('Proxy', const [
_proxy,
Proxy(name: 'HK-02', type: 'ss'),
Proxy(name: 'JP-01', type: 'ss'),
]),
],
);
container
.read(delayDataSourceProvider.notifier)
.setDelay(const Delay(url: _testUrl, name: 'HK-02', value: -1));
container
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(hideTimeoutProxies: true));
container.read(queryProvider(QueryTag.proxies).notifier).value = 'HK';
await actionOf(container).delayTestPageGroup('Proxy');
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
verify(() => core.asyncTestDelay(_testUrl, 'HK-02')).called(1);
verifyNever(() => core.asyncTestDelay(_testUrl, 'JP-01'));
},
);
test('a page group holds its testing mark until the run settles', () async {
final release = Completer<Delay?>();
when(
() => core.asyncTestDelay(_testUrl, 'HK-01'),
).thenAnswer((_) => release.future);
final container = _delayContainer(buildContainer);
container
.read(groupsProvider.notifier)
.update(
(_) => [
_group('Proxy', const [_proxy]),
],
);
final run = actionOf(container).delayTestPageGroup('Proxy');
await actionOf(container).delayTestPageGroup('Proxy');
expect(container.read(delayTestingGroupsProvider), {'Proxy'});
release.completeError(
const CoreMethodException(code: 'internal_error', message: 'boom'),
);
await run;
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
expect(container.read(delayTestingGroupsProvider), isEmpty);
});
test('stops the run once the transport is gone', () async {
var calls = 0;
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer((_) async {
+381 -9
View File
@@ -20,6 +20,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:riverpod/riverpod.dart';
import 'package:yaml/yaml.dart';
import '../helpers/test_profiles.dart';
@@ -124,6 +125,20 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
registerFallbackValue(const SetupParams(selectedMap: {}, testUrl: ''));
registerFallbackValue(
const UpdateParams(
tun: Tun(),
mixedPort: 7890,
allowLan: false,
findProcessMode: FindProcessMode.off,
mode: Mode.rule,
logLevel: LogLevel.info,
ipv6: false,
tcpConcurrent: false,
externalController: ExternalControllerStatus.close,
unifiedDelay: false,
),
);
});
late TestSetupAction action;
@@ -178,6 +193,34 @@ void main() {
expect(globalState.needInitStatus, isFalse);
});
test('safe mode runs the profile but never touches the listener', () async {
final scopedAction = TestSetupAction();
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(() => scopedAction),
commonActionProvider.overrideWith(TestCommonAction.new),
safeModeProvider.overrideWithValue(true),
],
);
addTearDown(scoped.dispose);
globalState.container = scoped;
scoped.read(setupActionProvider.notifier);
await scoped
.read(setupActionProvider.notifier)
.setRunning(true, initialize: true);
expect(scopedAction.coreRunningCalls, isEmpty);
expect(scopedAction.applyProfileCalls, 1);
expect(scoped.read(isStartProvider), isTrue);
await scoped.read(setupActionProvider.notifier).setRunning(false);
expect(scopedAction.coreRunningCalls, isEmpty);
expect(scoped.read(isStartProvider), isFalse);
});
test('starts the core once initialization is done', () async {
markInitialized();
@@ -225,25 +268,112 @@ void main() {
});
group('stop cleanup', () {
test('resets traffic counters and re-checks the ip', () async {
test('resets traffic counters', () async {
markInitialized();
await container.read(setupActionProvider.notifier).setRunning(true);
container.read(totalTrafficProvider.notifier).value = const Traffic(
up: 10,
down: 20,
);
final checkIpBefore = container.read(checkIpNumProvider);
await container.read(setupActionProvider.notifier).setRunning(false);
expect(action.trafficResets, 1);
expect(container.read(trafficsProvider).list, isEmpty);
expect(container.read(totalTrafficProvider), const Traffic());
expect(container.read(checkIpNumProvider), checkIpBefore + 1);
expect(container.read(runTimeProvider), isNull);
});
});
group('run time ticker', () {
late ProviderContainer scoped;
// Built inside each test, on its fake clock: futures made there never
// settle in the shared tearDown.
SetupAction buildScoped() {
scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(TestSetupAction.new),
commonActionProvider.overrideWith(TestCommonAction.new),
],
);
addTearDown(scoped.dispose);
globalState.container = scoped;
scoped
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(showTrayTitle: false));
scoped.read(initProvider.notifier).value = true;
return scoped.read(setupActionProvider.notifier);
}
int trafficUpdates() =>
(scoped.read(commonActionProvider.notifier) as TestCommonAction)
.trafficUpdates;
void setVisible(bool visible) =>
scoped.read(appVisibleProvider.notifier).value = visible;
testWidgets('reads traffic only while the app is visible', (tester) async {
final setup = buildScoped();
await setup.setRunning(true);
expect(trafficUpdates(), 1);
await tester.pump(const Duration(seconds: 2));
expect(trafficUpdates(), 3);
setVisible(false);
await tester.pump(const Duration(seconds: 5));
expect(trafficUpdates(), 3);
expect(scoped.read(isStartProvider), isTrue);
setVisible(true);
await tester.pump();
expect(trafficUpdates(), 4);
await tester.pump(const Duration(seconds: 1));
expect(trafficUpdates(), 5);
await setup.setRunning(false);
});
testWidgets('a start while hidden waits for the app to be shown', (
tester,
) async {
final setup = buildScoped();
setVisible(false);
await setup.setRunning(true);
await tester.pump(const Duration(seconds: 3));
expect(scoped.read(runTimeProvider), isNotNull);
expect(trafficUpdates(), 0);
setVisible(true);
await tester.pump();
expect(trafficUpdates(), 1);
await setup.setRunning(false);
});
testWidgets('the macOS menu bar title keeps traffic coming while hidden', (
tester,
) async {
final setup = buildScoped();
setVisible(false);
await setup.setRunning(true);
await tester.pump(const Duration(seconds: 2));
expect(trafficUpdates(), 0);
scoped
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(showTrayTitle: true));
await tester.pump(const Duration(seconds: 2));
expect(trafficUpdates(), system.isMacOS ? 3 : 0);
await setup.setRunning(false);
});
});
group('suspend', () {
test('skips starting the core on an excluded SSID', () async {
container.dispose();
@@ -369,6 +499,26 @@ void main() {
);
});
test('safe mode never asks, whatever the tun setting says', () async {
final scopedAction = TestSetupAction();
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(() => scopedAction),
safeModeProvider.overrideWithValue(true),
],
);
addTearDown(scoped.dispose);
scoped.read(setupActionProvider.notifier);
expect(await scopedAction.requestAdmin(true), isTrue);
expect(scopedAction.authorizeCalls, 0);
expect(
scoped.read(authorizedTunEnableProvider),
TunAuthorizationState.none,
);
});
test('does not ask again once the state left none', () async {
container.read(authorizedTunEnableProvider.notifier).value =
TunAuthorizationState.authorized;
@@ -412,6 +562,73 @@ void main() {
});
});
group('updateConfig', () {
test('safe mode pushes tun disabled even once authorized', () async {
final core = _MockCoreHandlerInterface();
when(() => core.updateConfig(any())).thenAnswer((_) async => '');
final scopedAction = TestSetupAction();
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(() => scopedAction),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
safeModeProvider.overrideWithValue(true),
],
);
addTearDown(scoped.dispose);
scoped.read(setupActionProvider.notifier);
scoped
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(tun: state.tun.copyWith(enable: true)),
);
scoped.read(authorizedTunEnableProvider.notifier).value =
TunAuthorizationState.authorized;
await scopedAction.updateConfig();
final params =
verify(() => core.updateConfig(captureAny())).captured.single
as UpdateParams;
expect(params.tun.enable, isFalse);
expect(scopedAction.authorizeCalls, 0);
});
test('safe mode closes the external controller', () async {
final core = _MockCoreHandlerInterface();
when(() => core.updateConfig(any())).thenAnswer((_) async => '');
final scopedAction = TestSetupAction();
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(() => scopedAction),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
safeModeProvider.overrideWithValue(true),
],
);
addTearDown(scoped.dispose);
scoped.read(setupActionProvider.notifier);
scoped
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
externalController: ExternalControllerStatus.open,
),
);
await scopedAction.updateConfig();
final params =
verify(() => core.updateConfig(captureAny())).captured.single
as UpdateParams;
expect(params.externalController, ExternalControllerStatus.close);
expect(
scoped.read(patchClashConfigProvider).externalController,
ExternalControllerStatus.open,
);
});
});
group('recoverMissingProfile', () {
ProviderContainer buildScoped(List<Profile> profiles, int? profileId) {
final scoped = ProviderContainer(
@@ -541,6 +758,12 @@ void main() {
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
await AppLocalizations.load(const Locale('en'));
originalLastConfigMd5 = globalState.lastConfigMd5;
globalState.packageInfo = PackageInfo(
appName: 'FlClash',
packageName: 'com.follow.clash',
version: '0.0.0',
buildNumber: '0',
);
});
tearDownAll(() {
@@ -565,6 +788,7 @@ void main() {
script: null,
overrideDns: false,
dns: Dns(),
dnsOverrideKeys: {},
);
test(
@@ -612,12 +836,6 @@ void main() {
).readAsString();
return '';
});
globalState.packageInfo = PackageInfo(
appName: 'FlClash',
packageName: 'com.follow.clash',
version: '0.0.0',
buildNumber: '0',
);
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
@@ -678,6 +896,160 @@ void main() {
},
);
test(
'safe mode writes a closed external controller into the profile',
() async {
final profile = Profile.normal(label: 'p');
final core = _MockCoreHandlerInterface();
when(() => core.getConfig(any())).thenAnswer((_) async => {});
String? pushedConfig;
when(() => core.setupConfig(any())).thenAnswer((_) async {
pushedConfig = await File(
await appPath.configFilePath,
).readAsString();
return '';
});
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
setupStateProvider.overrideWith(
(_, profileId) =>
nullProfileSetupState.copyWith(profileId: profileId),
),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
setupActionProvider.overrideWith(SetupAction.new),
safeModeProvider.overrideWithValue(true),
],
);
addTearDown(scoped.dispose);
scoped
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
externalController: ExternalControllerStatus.open,
),
);
final succeeded = await scoped
.read(setupActionProvider.notifier)
.applyProfile(force: true);
expect(succeeded, isTrue);
expect(pushedConfig, contains('external-controller: ""'));
expect(pushedConfig, isNot(contains('9090')));
},
);
test('a custom overwrite injects only the providers it names', () async {
final profile = Profile.normal(label: 'p');
final core = _MockCoreHandlerInterface();
when(() => core.getConfig(any())).thenAnswer(
(_) async => {
'proxy-providers': {
'bundled': {'type': 'http', 'url': 'https://example.com/b.yaml'},
},
},
);
const appProxy = ClashProvider(
id: 11,
kind: ProviderKind.proxy,
label: 'appProxies',
url: 'https://example.com/p.yaml',
);
const appRule = ClashProvider(
id: 12,
kind: ProviderKind.rule,
label: 'appRules',
url: 'https://example.com/r.yaml',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.mrs,
);
const unusedProxy = ClashProvider(
id: 13,
kind: ProviderKind.proxy,
label: 'unused',
url: 'https://example.com/u.yaml',
);
const localRule = ClashProvider(
id: 14,
kind: ProviderKind.rule,
label: 'localRules',
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.yaml,
);
final setupState = nullProfileSetupState.copyWith(
profileId: profile.id,
overwriteType: OverwriteType.custom,
proxyGroups: [
const ProxyGroup(
id: 1,
name: 'g',
type: GroupType.Selector,
use: ['appProxies', 'sub'],
),
],
rules: [
Rule.parse('RULE-SET,appRules,DIRECT', id: 2),
Rule.parse('RULE-SET,localRules,DIRECT', id: 3),
],
clashProviders: const [appProxy, appRule, unusedProxy, localRule],
profileProviders: const {'sub': 42},
);
final scoped = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
setupActionProvider.overrideWith(SetupAction.new),
],
);
addTearDown(scoped.dispose);
final res = await scoped
.read(setupActionProvider.notifier)
.getProfile(
setupState: setupState,
patchConfig: const PatchClashConfig(),
);
final config = loadYaml(res.yaml) as YamlMap;
final proxyProviders = config['proxy-providers'] as YamlMap;
expect(
proxyProviders['appProxies']['path'],
await appPath.getProviderCachePath(
ProviderKind.proxy,
appProxy.fileName,
),
);
expect(proxyProviders['sub']['type'], 'file');
expect(proxyProviders['sub']['path'], await appPath.getProfilePath('42'));
expect(proxyProviders.containsKey('unused'), isFalse);
expect(
proxyProviders['bundled']['path'],
startsWith(
await appPath.getProviderDirPath(
profile.id,
proxiesProviderDirectoryName,
),
),
);
expect(config['rule-providers']['appRules']['behavior'], 'domain');
expect(config['rule-providers']['appRules']['format'], 'mrs');
expect(
config['rule-providers']['appRules']['path'],
await appPath.getProviderCachePath(ProviderKind.rule, appRule.fileName),
);
final local = config['rule-providers']['localRules'] as YamlMap;
expect(local['type'], 'file');
expect(local.containsKey('url'), isFalse);
expect(
local['path'],
await appPath.getProviderCachePath(
ProviderKind.rule,
localRule.fileName,
),
);
});
test(
'a rejected setupConfig without a handoff reports failure, not success',
() async {
+54
View File
@@ -0,0 +1,54 @@
import 'package:drift/native.dart';
import 'package:fl_clash/common/feature.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_profiles.dart';
void main() {
late db.Database testDatabase;
setUp(() {
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
feature = const Feature(customProviders: true, customProxies: true);
});
tearDown(() async {
feature = const Feature();
await testDatabase.close();
});
testWidgets('a custom setup disposed before its queries return resolves', (
tester,
) async {
const profile = Profile(
id: 1,
label: 'Home',
autoUpdateDuration: Duration.zero,
overwriteType: OverwriteType.custom,
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const SizedBox.shrink(),
),
);
final state = await container.read(setupStateProvider(profile.id).future);
expect(state.profileProviders, {'Home': profile.id});
});
}
+323
View File
@@ -62,6 +62,178 @@ void main() {
expect(container.read(currentGroupsStateProvider).value, isEmpty);
});
test('group derivation ignores a change of selection only', () {
Group group(String now, String nestedNow) => Group(
name: 'Visible',
type: GroupType.Selector,
now: now,
hidden: false,
all: [
const Proxy(name: 'Node', type: 'Direct'),
Proxy(name: 'Auto', type: 'URLTest', now: nestedNow),
],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.rule));
container.read(groupsProvider.notifier).update((_) => [group('Node', 'a')]);
var notified = 0;
container.listen(currentGroupsStateProvider, (_, _) => notified++);
container.read(groupsProvider.notifier).update((_) => [group('Auto', 'b')]);
container.read(currentGroupsStateProvider);
expect(notified, 0);
});
test('hiding timed-out nodes leaves the rest of the group alone', () {
const testUrl = 'https://default.test';
container
.read(groupsProvider.notifier)
.update(
(_) => [
const Group(
name: 'Visible',
type: GroupType.Selector,
hidden: false,
all: [
Proxy(name: 'fast', type: 'ss'),
Proxy(name: 'slow', type: 'ss'),
],
),
],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.rule));
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(testUrl: testUrl));
container
.read(delayDataSourceProvider.notifier)
.setDelay(const Delay(url: testUrl, name: 'slow', value: -1));
expect(
container.read(visibleGroupsStateProvider).value.single.all,
hasLength(2),
);
container
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(hideTimeoutProxies: true));
expect(
container
.read(visibleGroupsStateProvider)
.value
.single
.all
.map((proxy) => proxy.name),
['fast'],
);
});
test('hidden timed-out nodes follow test batches and cleared delays', () {
const testUrl = 'https://default.test';
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.rule));
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(testUrl: testUrl));
container
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(hideTimeoutProxies: true));
void setGroup(String now) {
container
.read(groupsProvider.notifier)
.update(
(_) => [
Group(
name: 'Visible',
type: GroupType.Selector,
now: now,
hidden: false,
all: const [
Proxy(name: 'a', type: 'ss'),
Proxy(name: 'b', type: 'ss'),
Proxy(name: 'c', type: 'ss'),
],
),
],
);
}
Iterable<String> visibleNames() => container
.read(visibleGroupsStateProvider)
.value
.single
.all
.map((proxy) => proxy.name);
setGroup('a');
expect(visibleNames(), ['a', 'b', 'c']);
container
.read(delayDataSourceProvider.notifier)
.setDelay(const Delay(url: testUrl, name: 'b', value: -1));
setGroup('c');
expect(visibleNames(), ['a', 'b', 'c']);
container.read(sortNumProvider.notifier).add();
expect(visibleNames(), ['a', 'c']);
container
.read(delayDataSourceProvider.notifier)
.setDelay(const Delay(url: testUrl, name: 'c', value: -1));
setGroup('a');
expect(visibleNames(), ['a', 'c']);
container.read(delayDataSourceProvider.notifier).value = {};
expect(visibleNames(), ['a', 'b', 'c']);
});
test('hiding timed-out nodes keeps the core pick, never empties a group', () {
const testUrl = 'https://default.test';
const nodes = [Proxy(name: 'a', type: 'ss'), Proxy(name: 'b', type: 'ss')];
container
.read(groupsProvider.notifier)
.update(
(_) => [
const Group(
name: 'Auto',
type: GroupType.URLTest,
now: 'b',
hidden: false,
all: nodes,
),
const Group(
name: 'Balance',
type: GroupType.LoadBalance,
hidden: false,
all: nodes,
),
],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.rule));
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(testUrl: testUrl));
for (final node in nodes) {
container
.read(delayDataSourceProvider.notifier)
.setDelay(Delay(url: testUrl, name: node.name, value: -1));
}
container
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(hideTimeoutProxies: true));
final groups = container.read(visibleGroupsStateProvider).value;
expect(groups.first.all.map((proxy) => proxy.name), ['b']);
expect(groups.last.all.map((proxy) => proxy.name), ['a', 'b']);
});
test('navigation providers select items for width and current page', () {
container
.read(viewSizeProvider.notifier)
@@ -188,6 +360,38 @@ void main() {
},
);
test('proxy search matches the proxy type and needs every term', () {
container
.read(groupsProvider.notifier)
.update(
(_) => const [
Group(
name: 'Group A',
type: GroupType.Selector,
hidden: false,
all: [
Proxy(name: 'HK 01', type: 'Vmess'),
Proxy(name: 'HK 02', type: 'Trojan'),
Proxy(name: 'JP 01', type: 'Trojan'),
],
),
],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.rule));
List<String> names(String query) => [
for (final proxy
in container.read(filterGroupsStateProvider(query)).value.single.all)
proxy.name,
];
expect(names('trojan'), ['HK 02', 'JP 01']);
expect(names('hk TROJAN'), ['HK 02']);
expect(container.read(filterGroupsStateProvider('hk jp')).value, isEmpty);
});
test('runtime, VPN, tray, and DNS states follow live state', () {
container
.read(runTimeProvider.notifier)
@@ -254,6 +458,61 @@ void main() {
expect(container.read(proxyStateProvider).isStart, isFalse);
});
test('safe mode keeps every host integration off', () async {
await AppLocalizations.load(const Locale('en'));
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
safeModeProvider.overrideWithValue(true),
],
);
addTearDown(scoped.dispose);
scoped.listen(sharedStateProvider, (_, _) {});
scoped.read(runTimeProvider.notifier).update((_) => 1);
scoped
.read(networkSettingProvider.notifier)
.update(
(state) => state.copyWith(systemProxy: true, autoSetSystemDns: true),
);
scoped
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(tun: state.tun.copyWith(enable: true)),
);
scoped
.read(authorizedTunEnableProvider.notifier)
.update((_) => TunAuthorizationState.authorized);
scoped
.read(vpnSettingProvider.notifier)
.update((state) => state.copyWith(enable: true, systemProxy: true));
scoped
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(showTrayTitle: true));
scoped.listen(trayStateProvider, (_, _) {});
scoped
.read(hotKeyActionsProvider.notifier)
.update(
(_) => const [
HotKeyAction(
action: HotAction.view,
key: 1,
modifiers: {KeyboardModifier.control},
),
],
);
final tray = scoped.read(trayStateProvider);
expect(tray.safeMode, isTrue);
expect(tray.showTrayTitle, isTrue);
expect(tray.hotKeys.keys, [HotAction.view]);
expect(scoped.read(trayTitleStateProvider).showTrayTitle, isTrue);
expect(scoped.read(shouldPatchSystemDnsProvider), isFalse);
final vpnOptions = scoped.read(sharedStateProvider).vpnOptions!;
expect(vpnOptions.enable, isFalse);
expect(vpnOptions.systemProxy, isFalse);
});
test('selection and delay providers resolve groups and profile state', () {
final profile = Profile.normal().copyWith(
selectedMap: {'Selector': 'Leaf'},
@@ -302,6 +561,70 @@ void main() {
);
});
test('tray delays follow the listed groups and their test URLs', () {
const probeUrl = 'https://probe.example/204';
container
.read(groupsProvider.notifier)
.update(
(_) => const [
Group(
name: 'Selector',
type: GroupType.Selector,
hidden: false,
all: [
Proxy(name: 'Leaf', type: 'Direct'),
Proxy(name: 'Idle', type: 'Direct'),
],
),
Group(
name: 'Probe',
type: GroupType.URLTest,
hidden: false,
testUrl: probeUrl,
all: [Proxy(name: 'Leaf', type: 'Direct')],
),
],
);
container.read(delayDataSourceProvider.notifier)
..setDelay(const Delay(name: 'Leaf', url: defaultTestUrl, value: 42))
..setDelay(const Delay(name: 'Leaf', url: probeUrl, value: -1));
expect(container.read(trayDelaysProvider), {
'Selector': {'Leaf': 42},
'Probe': {'Leaf': -1},
});
});
test('tray hotkeys leave out bindings that cannot fire', () {
container.listen(trayStateProvider, (_, _) {});
container
.read(hotKeyActionsProvider.notifier)
.update(
(_) => const [
HotKeyAction(
action: HotAction.view,
key: 1,
modifiers: {KeyboardModifier.control},
),
HotKeyAction(
action: HotAction.start,
key: 2,
modifiers: {KeyboardModifier.capsLock},
),
HotKeyAction(
action: HotAction.tun,
key: 3,
modifiers: {KeyboardModifier.control},
),
],
);
container.read(hotKeyFailuresProvider.notifier).value = {
HotAction.tun: 'taken',
};
expect(container.read(trayStateProvider).hotKeys.keys, [HotAction.view]);
});
test('theme and simple derived providers cover fallback branches', () {
expect(container.read(currentBrightnessProvider), Brightness.dark);
container
+21
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
@@ -101,6 +103,25 @@ void main() {
await teardownView(tester);
});
testWidgets('shows the empty state until core answers', (tester) async {
final pending = Completer<List<TrackerInfo>>();
when(core.getConnections).thenAnswer((_) => pending.future);
await pumpConnections(tester);
expect(find.byType(NullStatus), findsOneWidget);
pending.complete([_tracker(id: 'a', host: 'alpha.test')]);
await tester.pump();
await tester.pump(commonDuration);
await tester.pump(commonDuration);
expect(find.byType(NullStatus), findsNothing);
expect(find.textContaining('alpha.test'), findsWidgets);
expect(tester.takeException(), null);
await teardownView(tester);
});
testWidgets('renders one row per reported connection', (tester) async {
when(core.getConnections).thenAnswer(
(_) async => [
+81 -29
View File
@@ -1,11 +1,16 @@
import 'dart:math';
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/views/views.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/scrollbar.dart';
import '../helpers/test_app.dart';
void main() {
@@ -46,16 +51,15 @@ void main() {
Finder hintFinder() => find.byKey(hintKey);
testWidgets('dragging the list floats the time hint next to the scrollbar', (
testWidgets('holding the scrollbar thumb floats the time hint beside it', (
tester,
) async {
await pumpLogsView(tester);
expect(hintFinder(), findsNothing);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Scrollable).first),
);
await gesture.moveBy(const Offset(0, 300));
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, -1));
await tester.pump();
expect(hintFinder(), findsOneWidget);
@@ -65,38 +69,41 @@ void main() {
expect(seedLogs().map((log) => log.dateTime), contains(label.data));
final scrollableRect = tester.getRect(find.byType(Scrollable).first);
// The hint is pinned to the scrollbar thumb. Material's minimum thumb
// length is 48, so at the newest end the thumb center rests 24px below
// the track's top edge.
// The list runs under the bar, so the scrollbar track starts at the
// bar's foot. The minimum thumb length is 48, so at the newest end the
// thumb center rests 24px below the track's top edge.
final trackTop = tester.getRect(find.byType(AppBar)).bottom;
expect(tester.getCenter(find.byKey(hintKey)).dy, closeTo(trackTop + 24, 6));
await gesture.moveBy(const Offset(0, 2000));
await tester.pump();
// Nothing floats over the foot, so the pill follows the thumb all the
// way down and rests centred on it, the thumb being the track's share of
// the content, both less the part under the bar.
final position = tester
.widget<Scrollable>(find.byType(Scrollable).first)
.controller!
.position;
final underBar = trackTop - scrollableRect.top;
final thumbExtent = max(
48.0,
(scrollableRect.bottom - trackTop) *
(position.viewportDimension - underBar) /
(position.maxScrollExtent + position.viewportDimension - underBar),
);
expect(
tester.getCenter(find.byKey(hintKey)).dy,
closeTo(scrollableRect.top + 24, 6),
);
for (var i = 0; i < 25; i++) {
await gesture.moveBy(const Offset(0, -2000));
await tester.pump();
}
// Near the oldest end the pill bottom-aligns above the FAB zone
// (56px FAB + 16px margin) with an 8px gap instead of following
// the thumb into it.
expect(
tester.getRect(find.byKey(hintKey)).bottom,
closeTo(scrollableRect.bottom - 72 - 8, 2),
closeTo(scrollableRect.bottom - thumbExtent / 2, 2),
);
await gesture.up();
// The pill outlives the gesture briefly so transient scroll ends do not
// blink it. No pumpAndSettle here: the fling's ballistic keeps frames
// scheduled for seconds of fake time, which would elapse straight past
// the hide window.
// The pill outlives the gesture briefly so letting go and grabbing again
// does not blink it.
await tester.pump(const Duration(milliseconds: 100));
expect(hintFinder(), findsOneWidget);
for (var i = 0; i < 20 && hintFinder().evaluate().isNotEmpty; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(hintFinder(), findsNothing);
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(hintFinder(), findsNothing);
});
testWidgets('auto scroll-to-end on new logs keeps the hint hidden', (
@@ -114,6 +121,51 @@ void main() {
expect(hintFinder(), findsNothing);
});
testWidgets('a one-line log is only as tall as its content', (tester) async {
await tester.pumpWidget(
const TestApp(
child: Scaffold(
body: Column(
children: [
LogItem(
log: Log(payload: 'short', dateTime: '2024-01-01 12:00:00'),
),
],
),
),
),
);
final item = tester.getRect(find.byType(LogItem));
final header = tester.getRect(find.byType(RecordHeader));
final body = tester.getRect(find.byType(SelectableText));
expect(header.top - item.top, 10);
expect(item.bottom - body.bottom, 12);
});
testWidgets('tapping the level reports it for filtering', (tester) async {
String? clicked;
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: LogItem(
log: const Log(
logLevel: LogLevel.warning,
payload:
'[TCP] dial Proxy 10.0.0.2:1 --> a.example:443 error: EOF',
dateTime: '2024-01-01 12:00:00',
),
onClick: (value) => clicked = value,
),
),
),
);
await tester.tap(find.text('warning'));
expect(clicked, 'warning');
});
group('LogListController', () {
final logs = seedLogs();
+8 -7
View File
@@ -40,12 +40,9 @@ void main() {
globalState.container = container;
});
// The editor blinks its caret forever, so `pumpAndSettle` never returns; and
// `encodeYamlTask` hands the encode to a real isolate, which only runs
// outside the fake-async zone.
// The editor blinks its caret forever, so `pumpAndSettle` never returns.
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
}
@@ -66,6 +63,10 @@ void main() {
return tester.widget<EditorPage>(find.byType(EditorPage));
}
String? loaded(WidgetTester tester) {
return tester.widget<EditorView>(find.byType(EditorView)).content;
}
testWidgets('asks for the profile it was given and shows its yaml', (
tester,
) async {
@@ -76,7 +77,7 @@ void main() {
expect(_StubSetupAction.requested, [7]);
expect(editor(tester).title, 'home');
expect(editor(tester).content, contains('mixed-port'));
expect(loaded(tester), 'mixed-port: 7890');
});
testWidgets('renders the editor before the content arrives', (tester) async {
@@ -96,11 +97,11 @@ void main() {
),
);
expect(editor(tester).content, isNull);
expect(loaded(tester), isNull);
await settle(tester);
expect(editor(tester).content, isNotNull);
expect(loaded(tester), isNotNull);
});
testWidgets('an unlabelled profile falls back to its id', (tester) async {
+86
View File
@@ -0,0 +1,86 @@
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/views/proxies/card.dart';
import 'package:fl_clash/views/proxies/list.dart';
import 'package:fl_clash/views/views.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
void main() {
testWidgets('the list scrolls its rows under the bar, pinning the header '
'at its foot', (tester) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final profile = Profile.normal().copyWith(
currentGroupName: 'Selector',
selectedMap: {'Selector': 'Proxy 1'},
unfoldSet: {'Selector'},
);
final group = Group(
name: 'Selector',
type: GroupType.Selector,
hidden: false,
now: 'Proxy 1',
all: List.generate(
160,
(index) => Proxy(name: 'Proxy $index', type: 'Direct'),
),
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
currentGroupsStateProvider.overrideWithValue(
GroupsState(value: [group]),
),
groupsProvider.overrideWithValue([group]),
],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1400, 1000));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ProxiesView()),
),
);
container
.read(proxiesStyleSettingProvider.notifier)
.update((state) => state.copyWith(type: ProxiesType.list));
await tester.pump();
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
expect(tester.getRect(find.byType(ListHeader)).top, barBottom + 8);
await tester.drag(find.byType(ProxyCard).first, const Offset(0, -300));
await tester.pump();
expect(tester.getRect(find.byType(ListHeader)).top, barBottom + 8);
expect(
find
.byType(ProxyCard)
.evaluate()
.map((element) => tester.getRect(find.byWidget(element.widget))),
contains(
predicate<Rect>(
(rect) => rect.top < barBottom && rect.bottom > 0,
'a card under the bar',
),
),
);
expect(tester.takeException(), isNull);
});
}
+28 -23
View File
@@ -4,10 +4,12 @@ import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/connection/requests.dart';
import 'package:fl_clash/widgets/null_status.dart';
import 'package:fl_clash/widgets/scroll.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/scrollbar.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
@@ -122,7 +124,7 @@ void main() {
await teardownView(tester);
});
testWidgets('dragging the list floats the start time next to the scrollbar', (
testWidgets('holding the scrollbar thumb floats the start time beside it', (
tester,
) async {
final requests = List.generate(
@@ -149,10 +151,9 @@ void main() {
const hintKey = ValueKey('scrollbarHintPill');
expect(find.byKey(hintKey), findsNothing);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Scrollable).first),
);
await gesture.moveBy(const Offset(0, 300));
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, -1));
await tester.pump();
expect(find.byKey(hintKey), findsOneWidget);
@@ -161,40 +162,44 @@ void main() {
);
expect(label.data, requests.last.start.showFull);
// The hint is pinned to the thumb: at the newest end the thumb center
// rests 24px (half the 48px minimum thumb) below the track's top edge.
// rests 24px (half the 48px minimum thumb) below the track's top edge,
// which the list running under the bar leaves at the bar's foot.
expect(
tester.getCenter(find.byKey(hintKey)).dy,
closeTo(tester.getRect(find.byType(Scrollable).first).top + 24, 6),
closeTo(tester.getRect(find.byType(AppBar)).bottom + 24, 6),
);
await gesture.up();
// The pill outlives the gesture briefly so transient scroll ends do not
// blink it. No pumpAndSettle here: the fling's ballistic keeps frames
// scheduled for seconds of fake time, which would elapse straight past
// the hide window.
await tester.pump(const Duration(milliseconds: 100));
expect(find.byKey(hintKey), findsOneWidget);
for (var i = 0; i < 20 && find.byKey(hintKey).evaluate().isNotEmpty; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(find.byKey(hintKey), findsNothing);
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(find.byKey(hintKey), findsNothing);
await teardownView(tester);
});
testWidgets('the scroll-to-end button toggles its icon', (tester) async {
seedRequests([_tracker(id: 'a', host: 'alpha.test')]);
testWidgets('following pauses when the list is scrolled off its head and '
'resumes when it is scrolled back', (tester) async {
seedRequests([for (var i = 0; i < 60; i++) _tracker(id: '$i')]);
await pumpRequests(tester);
expect(find.byIcon(Icons.block), findsOneWidget);
expect(find.byIcon(Icons.vertical_align_top), findsNothing);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.vertical_align_top), findsOneWidget);
final box = find.byType(ScrollToEndBox<TrackerInfo>);
bool following() => tester.widget<ScrollToEndBox<TrackerInfo>>(box).enable;
expect(find.byType(FloatingActionButton), findsNothing);
expect(following(), isTrue);
// The list is reversed, so its head is the end of the scroll extent and
// an upward drag moves away from it.
await tester.drag(box, const Offset(0, -300));
await tester.pumpAndSettle();
expect(following(), isFalse);
await tester.drag(box, const Offset(0, 600));
await tester.pumpAndSettle();
expect(following(), isTrue);
await teardownView(tester);
});
+5 -3
View File
@@ -2,6 +2,7 @@ import 'dart:io';
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/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
@@ -12,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' hide context;
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
void main() {
@@ -80,9 +82,9 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(DecorationListItem), findsNWidgets(6));
expect(find.byType(ItemPositionProvider), findsNWidgets(4));
expect(find.byType(ItemPositionProvider), findsNWidgets(6));
expect(find.byType(Switch), findsOneWidget);
expect(find.byIcon(Icons.more_vert), findsNWidgets(4));
expect(find.byGlyph(AppGlyphs.more), findsNWidgets(4));
expect(find.byType(FutureBuilder<FileInfo?>), findsNWidgets(4));
for (final url in defaultGeoXUrl.values) {
expect(find.text(url), findsNothing);
@@ -93,7 +95,7 @@ void main() {
matching: find.byType(DecorationListItem),
);
await tester.tap(
find.descendant(of: mmdbItem, matching: find.byIcon(Icons.more_vert)),
find.descendant(of: mmdbItem, matching: find.byGlyph(AppGlyphs.more)),
);
await tester.pumpAndSettle();
+107
View File
@@ -1,3 +1,4 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -129,4 +130,110 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('TrackerInfoItem reads from the rule to the node', (
tester,
) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
homeBuilder: (child) => Scaffold(body: child),
child: TrackerInfoItem(
trackerInfo: _tracker(
rulePayload: 'example.com',
process: 'chrome',
uid: 1000,
sourceIP: '1.2.3.4',
sourcePort: '8080',
destinationIP: '5.6.7.8',
destinationPort: '443',
host: 'example.com',
chains: const ['Node', 'Group'],
),
detailTitle: 'detail',
),
),
);
await tester.pump();
final rule = tester.getCenter(find.text('DOMAIN-SUFFIX(example.com)'));
final group = tester.getCenter(find.text('Group'));
final node = tester.getCenter(find.text('Node'));
expect(rule.dx, lessThan(group.dx));
expect(group.dx, lessThan(node.dx));
expect(find.text('example.com:443 5.6.7.8'), findsOneWidget);
expect(find.text('chrome(1000) · 1.2.3.4:8080'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('TrackerInfoItem shows speed only for a live connection', (
tester,
) async {
Future<void> pumpItem({required bool isLive}) async {
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
homeBuilder: (child) => Scaffold(body: child),
child: TrackerInfoItem(
key: ValueKey(isLive),
trackerInfo: _tracker(
host: 'example.com',
).copyWith(uploadSpeed: 2048, downloadSpeed: 4096),
isLive: isLive,
action: const SizedBox(key: ValueKey('action')),
detailTitle: 'detail',
),
),
);
await tester.pump();
}
await pumpItem(isLive: false);
expect(find.text(DateTime(2026, 1, 1, 10, 30).showFull), findsOneWidget);
expect(find.textContaining('4KB/s'), findsNothing);
await pumpItem(isLive: true);
expect(find.text(DateTime(2026, 1, 1, 10, 30).showFull), findsNothing);
expect(find.textContaining('4KB/s'), findsOneWidget);
expect(
find.descendant(
of: find.byType(RecordHeader),
matching: find.byKey(const ValueKey('action')),
),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('TrackerInfoDetailView lists the chain from group to node', (
tester,
) async {
await tester.pumpWidget(
TestApp(
homeBuilder: (child) => Scaffold(body: child),
child: SheetProvider(
type: SheetType.page,
child: TrackerInfoDetailView(
trackerInfo: _tracker(chains: const ['Node', 'Group']),
),
),
),
);
await tester.pump();
await tester.scrollUntilVisible(
find.text('Node'),
100,
scrollable: find.byType(Scrollable),
);
expect(
tester.getCenter(find.text('Group')).dx,
lessThan(tester.getCenter(find.text('Node')).dx),
);
await tester.pumpWidget(const SizedBox.shrink());
});
}
+42
View File
@@ -1,7 +1,10 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/connection/connections.dart';
import 'package:fl_clash/features/features.dart';
@@ -9,6 +12,7 @@ import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
void main() {
late ProviderContainer container;
@@ -94,6 +98,42 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a row blocks its own connection and refreshes the list', (
tester,
) async {
final core = _MockCoreHandlerInterface();
when(() => core.closeConnection(any())).thenAnswer((_) async => true);
container.dispose();
container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
globalState.container = container;
var connections = buildConnections(2);
var readCount = 0;
await pumpConnections(
tester,
connectionsReader: () async {
readCount++;
return connections;
},
);
await tester.pump();
final reads = readCount;
connections = connections.sublist(1);
await tester.tap(find.byTooltip('Block connection').first);
await tester.pumpAndSettle();
verify(() => core.closeConnection('0')).called(1);
expect(readCount, greaterThan(reads));
expect(find.textContaining('host-0.com'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('ConnectionsView polls only while the page is active', (
tester,
) async {
@@ -206,3 +246,5 @@ class _TestApp extends StatelessWidget {
);
}
}
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
+52
View File
@@ -0,0 +1,52 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
void main() {
testWidgets('DetailRow centers its label beside a wrapped value', (
tester,
) async {
const value = 'udp://119.29.29.29:53 udp://223.5.5.5:53';
await tester.pumpWidget(
TestApp(
child: Center(
child: SizedBox(
width: 240,
child: DetailRow.text(title: 'Source', value: value),
),
),
),
);
final label = tester.getRect(find.text('Source'));
final text = tester.getRect(find.text(value));
expect(text.height, greaterThan(label.height));
expect(text.center.dy, moreOrLessEquals(label.center.dy, epsilon: 1));
expect(text.right, greaterThan(label.right));
});
testWidgets('DetailRow without a value lets a long title wrap', (
tester,
) async {
const answer = '2400:cb00:2048:1::6814:1c3b 2400:cb00:2048:1::6814:1d3b';
Future<double> heightAt(double width) async {
await tester.pumpWidget(
TestApp(
child: Center(
child: SizedBox(
width: width,
child: const DetailRow(title: answer, copyText: answer),
),
),
),
);
return tester.getRect(find.text(answer)).height;
}
final singleLine = await heightAt(800);
expect(await heightAt(240), greaterThan(singleLine));
expect(tester.takeException(), isNull);
});
}
+212 -31
View File
@@ -2,43 +2,224 @@ import 'package:fl_clash/widgets/scroll.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('the hint follows the thumb of a list in reading order', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FloatingScrollbar(
controller: controller,
hintBuilder: (fraction) => fraction.toStringAsFixed(1),
child: ListView.builder(
controller: controller,
itemCount: 200,
itemBuilder: (_, index) =>
SizedBox(height: 40, child: Text('$index')),
),
),
),
import '../helpers/scrollbar.dart';
const _itemHeight = 40.0;
const _itemCount = 200;
const _hintKey = ValueKey('scrollbarHintPill');
const _rateKey = ValueKey('scrollbarScrubRate');
Widget _list(
ScrollController controller, {
bool thumbVisibility = false,
int itemCount = _itemCount,
}) => MaterialApp(
home: Scaffold(
body: FloatingScrollbar(
controller: controller,
thumbVisibility: thumbVisibility,
hintBuilder: (fraction) => fraction.toStringAsFixed(2),
child: ListView.builder(
controller: controller,
itemCount: itemCount,
itemBuilder: (_, index) =>
SizedBox(height: _itemHeight, child: Text('$index')),
),
);
),
),
);
Future<ScrollController> _pumpList(
WidgetTester tester, {
bool thumbVisibility = false,
}) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(_list(controller, thumbVisibility: thumbVisibility));
return controller;
}
void main() {
testWidgets('dragging the content leaves the hint hidden', (tester) async {
await _pumpList(tester);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Scrollable)),
);
for (var i = 0; i < 25; i++) {
await gesture.moveBy(const Offset(0, -2000));
await tester.pump();
}
final listRect = tester.getRect(find.byType(Scrollable));
final hint = find.byKey(const ValueKey('scrollbarHintPill'));
expect(hint, findsOneWidget);
expect(tester.getCenter(hint).dy, greaterThan(listRect.center.dy));
await gesture.moveBy(const Offset(0, -300));
await tester.pump();
expect(find.byKey(_hintKey), findsNothing);
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 1));
await tester.pumpAndSettle(const Duration(seconds: 2));
});
testWidgets('holding the thumb floats the hint beside it until let go', (
tester,
) async {
await _pumpList(tester);
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, 200));
await tester.pump();
final hint = find.byKey(_hintKey);
expect(hint, findsOneWidget);
expect(tester.getCenter(hint).dy, closeTo(thumb.dy + 200, 2));
expect(tester.getRect(hint).right, lessThan(thumb.dx - 20));
expect(find.byKey(_rateKey), findsNothing);
await gesture.up();
await tester.pump(const Duration(milliseconds: 300));
expect(hint, findsOneWidget);
await tester.pumpAndSettle(const Duration(seconds: 2));
expect(hint, findsNothing);
});
testWidgets('a hint whose list stops scrolling still goes away', (
tester,
) async {
final controller = await _pumpList(tester);
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, 200));
await tester.pumpAndSettle();
expect(find.byKey(_hintKey), findsOneWidget);
await gesture.up();
await tester.pumpWidget(_list(controller, itemCount: 3));
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
await tester.pumpWidget(_list(controller));
await tester.drag(find.byType(Scrollable), const Offset(0, -300));
await tester.pump();
expect(find.byKey(_hintKey), findsNothing);
await tester.pumpAndSettle();
});
testWidgets('the hint fades in when the thumb is grabbed', (tester) async {
await _pumpList(tester);
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, 10));
await tester.pump();
double hintOpacity() => tester
.widget<FadeTransition>(
find
.ancestor(
of: find.byKey(_hintKey),
matching: find.byType(FadeTransition),
)
.first,
)
.opacity
.value;
expect(hintOpacity(), 0);
await tester.pump();
await tester.pump(const Duration(milliseconds: 75));
expect(hintOpacity(), inExclusiveRange(0, 1));
await tester.pump(const Duration(milliseconds: 100));
expect(hintOpacity(), 1);
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 2));
});
testWidgets('straying sideways keeps the scrub speed on desktop', (
tester,
) async {
final controller = await _pumpList(tester);
final track = tester.getSize(find.byType(Scrollable)).height;
final pixelsPerThumbPixel =
controller.position.maxScrollExtent / (track - 48);
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(-110, 0));
await gesture.moveBy(const Offset(0, 40));
await tester.pump();
expect(find.byKey(_rateKey), findsNothing);
expect(controller.offset, closeTo(40 * pixelsPerThumbPixel, 1));
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 2));
}, variant: TargetPlatformVariant.desktop());
testWidgets('the thumb eases wider while held and back when let go', (
tester,
) async {
await _pumpList(tester);
final thumb = await revealScrollbarThumb(tester);
expect(scrollbarPainter(tester).thickness, 6);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, 10));
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
expect(scrollbarPainter(tester).thickness, inExclusiveRange(6, 10));
await tester.pump(const Duration(milliseconds: 100));
expect(scrollbarPainter(tester).thickness, 10);
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 2));
expect(scrollbarPainter(tester).thickness, 6);
});
testWidgets('straying sideways from the thumb slows the scrub', (
tester,
) async {
final controller = await _pumpList(tester);
final position = controller.position;
final track = tester.getSize(find.byType(Scrollable)).height;
final pixelsPerThumbPixel = position.maxScrollExtent / (track - 48);
final thumb = await revealScrollbarThumb(tester);
final gesture = await tester.startGesture(thumb);
await gesture.moveBy(const Offset(0, 40));
await tester.pump();
expect(controller.offset, closeTo(40 * pixelsPerThumbPixel, 1));
await gesture.moveBy(const Offset(-110, 0));
await tester.pump();
expect(find.byKey(_rateKey), findsOneWidget);
expect(tester.widget<Text>(find.byKey(_rateKey)).data, '×1/4');
await gesture.moveBy(const Offset(0, 40));
await tester.pump();
expect(controller.offset, closeTo(50 * pixelsPerThumbPixel, 1));
await gesture.moveBy(const Offset(110, 0));
await tester.pump();
expect(find.byKey(_rateKey), findsNothing);
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 2));
});
testWidgets('a pinned thumb can be grabbed without scrolling first', (
tester,
) async {
final controller = await _pumpList(tester, thumbVisibility: true);
await tester.pumpAndSettle();
final listRect = tester.getRect(find.byType(Scrollable));
final gesture = await tester.startGesture(
Offset(listRect.right - 5, listRect.top + 24),
);
await gesture.moveBy(const Offset(0, 40));
await tester.pump();
expect(controller.offset, greaterThan(0));
expect(find.byKey(_hintKey), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle(const Duration(seconds: 2));
expect(scrollbarPainter(tester).fadeoutOpacityAnimation.value, 1);
});
}
@@ -83,4 +83,43 @@ void main() {
expect(find.text('b'), findsOneWidget);
expect(topOf(tester, 'b'), 40);
});
testWidgets('rows entering in one update share a single ticker', (
tester,
) async {
final visible = [for (var i = 0; i < 30; i++) '$i'];
await tester.pumpWidget(buildList(visible));
await tester.pumpWidget(
buildList([...visible, for (var i = 30; i < 70; i++) '$i']),
);
await tester.pump();
expect(tester.binding.transientCallbackCount, 1);
await tester.pumpAndSettle();
expect(tester.binding.transientCallbackCount, 0);
});
testWidgets('a change too large to animate swaps the rows at once', (
tester,
) async {
final many = [for (var i = 0; i < 100; i++) '$i'];
await tester.pumpWidget(buildList(many));
await tester.pumpWidget(buildList(const ['0', '1']));
await tester.pump();
expect(
find.descendant(
of: find.byType(KeyedAnimatedList<String>),
matching: find.byType(SizeTransition),
),
findsNothing,
);
expect(find.text('2'), findsNothing);
expect(topOf(tester, '1'), 40);
await tester.pumpWidget(buildList(many));
await tester.pump();
expect(find.text('10'), findsOneWidget);
expect(topOf(tester, '10'), 400);
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -11,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_profiles.dart';
Profile urlProfile(String label) =>
@@ -120,7 +122,7 @@ void main() {
matching: find.byType(ListItem),
);
await tester.tap(
find.descendant(of: profileItem, matching: find.byIcon(Icons.more_vert)),
find.descendant(of: profileItem, matching: find.byGlyph(AppGlyphs.more)),
);
await tester.pumpAndSettle();
+164
View File
@@ -0,0 +1,164 @@
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/views/proxies/list.dart';
import 'package:fl_clash/widgets/widgets.dart' hide ListHeader;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _RecordingProxiesAction extends ProxiesAction {
static final List<String> tested = [];
@override
Future<void> delayTestPageGroup(String groupName) async {
tested.add(groupName);
}
}
Group _group(String name, int count) => Group(
type: GroupType.Selector,
name: name,
all: [for (var i = 0; i < count; i++) Proxy(name: '$name-$i', type: 'ss')],
);
void main() {
late ProviderContainer container;
Future<void> pumpList(
WidgetTester tester, {
Map<String, String> selectedMap = const {},
}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final groups = [_group('G0', 40), _group('G1', 30), _group('G2', 60)];
final profile = Profile.normal().copyWith(
selectedMap: selectedMap,
unfoldSet: {'G0', 'G1', 'G2'},
);
_RecordingProxiesAction.tested.clear();
container = ProviderContainer(
overrides: [
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentGroupsStateProvider.overrideWithValue(
GroupsState(value: groups),
),
proxiesActionProvider.overrideWith(_RecordingProxiesAction.new),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(groupsProvider.notifier).value = groups;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
homeBuilder: (child) => Scaffold(
body: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(padding: const EdgeInsets.only(top: 64)),
child: child,
),
),
),
child: const ProxiesListView(),
),
),
);
await tester.pumpAndSettle();
}
Finder headerOf(String groupName) => find.ancestor(
of: find.text(groupName, findRichText: true),
matching: find.byType(ListHeader),
);
Finder actionOf(String groupName, String tooltip) => find.descendant(
of: headerOf(groupName),
matching: find.byTooltip(tooltip),
);
Future<void> revealHeaderOf(WidgetTester tester, String groupName) async {
await tester.scrollUntilVisible(
actionOf(groupName, 'Scroll to selected'),
200,
scrollable: find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
await tester.pumpAndSettle();
}
testWidgets('scroll to selected lands the row under the pinned header', (
tester,
) async {
await pumpList(tester, selectedMap: {'G1': 'G1-25'});
await revealHeaderOf(tester, 'G1');
await tester.tap(actionOf('G1', 'Scroll to selected'));
await tester.pumpAndSettle();
final header = tester.getRect(headerOf('G1'));
final card = tester.getRect(find.byKey(const ValueKey('G1.G1-25')).first);
expect(card.top, header.bottom + 8);
});
testWidgets('scroll to selected stays put without a selection', (
tester,
) async {
await pumpList(tester);
await revealHeaderOf(tester, 'G1');
final before = tester.getRect(headerOf('G1'));
await tester.tap(actionOf('G1', 'Scroll to selected'));
await tester.pumpAndSettle();
expect(tester.getRect(headerOf('G1')), before);
});
testWidgets('the delay test button tests its own group only', (tester) async {
await pumpList(tester);
await tester.tap(actionOf('G0', 'Delay test'));
await tester.pump();
expect(_RecordingProxiesAction.tested, ['G0']);
});
testWidgets('a group under test spins its button and ignores taps', (
tester,
) async {
await pumpList(tester);
container.read(delayTestingGroupsProvider.notifier).start('G0');
await tester.pump();
expect(
find.descendant(
of: headerOf('G0'),
matching: find.byType(CommonCircleLoading),
),
findsOneWidget,
);
await tester.tap(actionOf('G0', 'Delay test'), warnIfMissed: false);
await tester.pump();
expect(_RecordingProxiesAction.tested, isEmpty);
container.read(delayTestingGroupsProvider.notifier).stop('G0');
await tester.pump();
expect(find.byType(CommonCircleLoading), findsNothing);
});
}
+57
View File
@@ -1,5 +1,6 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
@@ -105,6 +106,62 @@ void main() {
},
);
testWidgets('the last tab scrolls clear of the more button fade', (
tester,
) async {
globalContainer
.read(_tabStateProvider.notifier)
.set(_tabState([for (var i = 0; i < 12; i++) _group('Group $i')]));
await pumpTabView(tester);
final tabs = find.descendant(
of: find.byType(TabBar),
matching: find.byType(Scrollable),
);
final position = tester.state<ScrollableState>(tabs).position;
position.jumpTo(position.maxScrollExtent);
await tester.pump();
final label = tester.getRect(find.text('Group 11', findRichText: true));
final more = tester.getRect(find.byType(IconButton));
expect(label.right, lessThanOrEqualTo(more.left - kTabLabelPadding.right));
});
testWidgets('scroll to selected brings the row to the top of the grid', (
tester,
) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final group = Group(
type: GroupType.Selector,
name: 'B',
all: [for (var i = 0; i < 80; i++) Proxy(name: 'B-$i', type: 'ss')],
);
globalContainer.read(groupsProvider.notifier).value = [group];
final profile = globalContainer.read(currentProfileProvider)!;
globalContainer
.read(profilesProvider.notifier)
.put(profile.copyWith(selectedMap: {'B': 'B-41'}));
globalContainer
.read(_tabStateProvider.notifier)
.set(_tabState([_group('A'), group]));
final key = await pumpTabView(tester);
await tester.pumpAndSettle();
final firstRowTop = tester
.getRect(find.text('B-0', findRichText: true).first)
.top;
key.currentState?.scrollToGroupSelected();
await tester.pumpAndSettle();
expect(
tester.getRect(find.text('B-41', findRichText: true).first).top,
firstRowTop,
);
});
testWidgets('rebuilds the tab bar when groups return', (tester) async {
final key = await pumpTabView(tester);
+149
View File
@@ -0,0 +1,149 @@
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/views/proxies/card.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
const _testUrl = 'https://example.com/generate_204';
class _RecordingProxiesAction extends ProxiesAction {
static final List<String> tested = [];
@override
Future<void> proxyDelayTest(Proxy proxy, [String? testUrl]) async {
tested.add(proxy.name);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ProviderContainer container;
setUp(() {
_RecordingProxiesAction.tested.clear();
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
proxiesActionProvider.overrideWith(_RecordingProxiesAction.new),
delayDataSourceProvider.overrideWithBuild(
(_, _) => {
_testUrl: {'proxy 0': 123, 'proxy 1': 456},
},
),
],
);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = const Size(900, 600);
});
tearDown(() => container.dispose());
Future<void> pumpCards(WidgetTester tester, ProxyCardType type) async {
tester.view.physicalSize = const Size(900, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
homeBuilder: (child) => Scaffold(body: child),
child: Column(
children: [
for (var index = 0; index < 2; index++)
SizedBox(
height: 120,
child: ProxyCard(
key: ValueKey('proxy $index'),
groupName: 'group',
testUrl: _testUrl,
proxy: Proxy(name: 'proxy $index', type: 'Shadowsocks'),
groupType: GroupType.Selector,
type: type,
),
),
],
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> focusCard(WidgetTester tester, int index) async {
for (var attempt = 0; attempt < 10; attempt++) {
final context = FocusManager.instance.primaryFocus?.context;
if (context?.findAncestorWidgetOfExactType<ProxyCard>()?.key ==
ValueKey('proxy $index')) {
return;
}
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
fail('proxy card $index was not reachable by tab');
}
for (final type in ProxyCardType.values) {
testWidgets('arrow right from a ${type.name} proxy card focuses its '
'delay control', (tester) async {
await pumpCards(tester, type);
for (var index = 0; index < 2; index++) {
await focusCard(tester, index);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final focusNode = FocusManager.instance.primaryFocus;
expect(focusNode, isA<SkipTraversalFocusNode>());
expect(
find.descendant(
of: find.byWidget(focusNode!.context!.widget),
matching: find.text(index == 0 ? '123 ms' : '456 ms'),
),
findsOneWidget,
);
}
});
}
testWidgets('tab walks card to card without stopping on the delay control', (
tester,
) async {
await pumpCards(tester, ProxyCardType.shrink);
await focusCard(tester, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
final context = FocusManager.instance.primaryFocus?.context;
expect(
context?.findAncestorWidgetOfExactType<ProxyCard>()?.key,
const ValueKey('proxy 1'),
);
});
testWidgets('enter on the delay control runs the delay test', (tester) async {
await pumpCards(tester, ProxyCardType.shrink);
await focusCard(tester, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(FocusManager.instance.primaryFocus, isA<SkipTraversalFocusNode>());
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(_RecordingProxiesAction.tested, ['proxy 0']);
});
}
+41 -1
View File
@@ -61,7 +61,9 @@ void main() {
),
);
final scrollBarContext = tester.element(find.byType(Scrollbar).first);
final scrollBarContext = tester.element(
find.byWidgetPredicate((widget) => widget is RawScrollbar).first,
);
expect(
MediaQuery.paddingOf(scrollBarContext),
viewPadding.copyWith(top: viewPadding.top + sheetAppBarHeight),
@@ -110,6 +112,44 @@ void main() {
},
);
testWidgets(
'a scroll bar given a list leaves a list nested in it the ambient bar',
variant: _desktop,
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
const ambientPadding = EdgeInsets.only(top: 12);
await tester.pumpWidget(
TestApp(
child: ScrollConfiguration(
behavior: const BaseScrollBehavior(
scrollbarPadding: ambientPadding,
),
child: CommonScrollBar(
controller: controller,
child: ListView(
controller: controller,
children: [
SizedBox(height: 200, child: _list()),
for (var index = 0; index < 30; index++)
SizedBox(height: 40, child: Text('row $index')),
],
),
),
),
),
);
final bars = tester.widgetList<CommonScrollBar>(
find.byType(CommonScrollBar),
);
expect(bars.where((bar) => bar.controller == controller), hasLength(1));
final nested = bars.singleWhere((bar) => bar.controller != controller);
expect(nested.padding, ambientPadding);
},
);
testWidgets('a sheet header lets what scrolls under it read through', (
tester,
) async {
+195 -4
View File
@@ -1,5 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/request.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -8,23 +15,58 @@ import 'package:fl_clash/views/about.dart';
import 'package:fl_clash/views/config/scripts.dart';
import 'package:fl_clash/views/profiles/overwrite/standard.dart';
import 'package:fl_clash/views/proxies/setting.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/gestures.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _PendingAdapter implements HttpClientAdapter {
final response = Completer<ResponseBody>();
int requests = 0;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) {
requests++;
return response.future;
}
@override
void close({bool force = false}) {}
}
class _TestScripts extends Scripts {
_TestScripts(this.initial);
final List<Script> initial;
final orders = <(int, int)>[];
@override
Stream<List<Script>> build() => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {
orders.add((oldIndex, newIndex));
}
}
class _RecordingScriptsAction extends ScriptsAction {
final updated = <Script>[];
@override
Future<void> updateScript(Script script) async {
updated.add(script);
}
}
class _TestGlobalRules extends GlobalRules {
@@ -155,6 +197,13 @@ void main() {
expect(find.byType(AboutView), findsOneWidget);
expect(find.text('Telegram'), findsOneWidget);
final hero = find.byWidgetPredicate(
(widget) => widget.runtimeType.toString() == '_AboutHero',
);
expect(
tester.getRect(hero).top,
tester.getRect(find.byType(AppBar)).bottom + 12,
);
expect(tester.takeException(), null);
final scrollables = find.byType(Scrollable);
@@ -167,7 +216,51 @@ void main() {
expect(tester.takeException(), null);
});
testWidgets('scripts view renders stored scripts and selects one', (
testWidgets('about view shows progress while checking for updates', (
tester,
) async {
final container = _containerFor(tester);
final originalAdapter = request.dio.httpClientAdapter;
addTearDown(() => request.dio.httpClientAdapter = originalAdapter);
final adapter = _PendingAdapter();
request.dio.httpClientAdapter = adapter;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: AboutView()),
),
);
await tester.pump();
expect(find.byType(LinearProgressIndicator), findsNothing);
await tester.tap(find.text('Check for updates'));
await tester.pump();
expect(find.byType(LinearProgressIndicator), findsOneWidget);
await tester.tap(find.text('Check for updates'));
await tester.pump(const Duration(milliseconds: 100));
expect(adapter.requests, 1);
adapter.response.complete(
ResponseBody.fromString(
jsonEncode({'tag_name': 'v0.0.0'}),
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.byType(LinearProgressIndicator), findsNothing);
expect(find.text('The app is already up to date'), findsOneWidget);
expect(tester.takeException(), null);
});
testWidgets('scripts view lists scripts and offers the add menu', (
tester,
) async {
final scripts = List.generate(
@@ -194,11 +287,109 @@ void main() {
expect(find.byType(ScriptsView), findsOneWidget);
expect(find.text('Script 0'), findsOneWidget);
expect(find.text('Script 3'), findsOneWidget);
expect(find.byGlyph(AppGlyphs.more), findsNWidgets(4));
expect(tester.takeException(), null);
await tester.tap(find.text('Script 1'));
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(tester.takeException(), null);
expect(find.text('Start from scratch'), findsOneWidget);
expect(find.text('Import from URL'), findsOneWidget);
expect(find.text('Import from file'), findsOneWidget);
});
testWidgets('scripts view offers sync only for scripts with a source URL', (
tester,
) async {
const url = 'https://example.com/remote.js';
final local = Script(id: 1, label: 'Local', lastUpdateTime: DateTime(2026));
final remote = Script(
id: 2,
label: 'Remote',
lastUpdateTime: DateTime(2026),
url: url,
);
final action = _RecordingScriptsAction();
final container = _containerFor(
tester,
overrides: [
scriptsProvider.overrideWith(() => _TestScripts([local, remote])),
scriptsActionProvider.overrideWith(() => action),
],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ScriptsView()),
),
);
await tester.pump();
expect(find.text(url), findsNothing);
Finder menuOf(String label) => find.descendant(
of: find.ancestor(
of: find.text(label),
matching: find.byType(DecorationListItem),
),
matching: find.byGlyph(AppGlyphs.more),
);
await tester.tap(menuOf('Local'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
expect(find.text('Sync'), findsNothing);
expect(find.text('URL'), findsNothing);
await tester.tapAt(Offset.zero);
await tester.pumpAndSettle();
await tester.tap(menuOf('Remote'));
await tester.pumpAndSettle();
expect(find.text('Sync'), findsOneWidget);
expect(find.text('URL'), findsOneWidget);
await tester.tap(find.text('Sync'));
await tester.pump();
expect(action.updated, [remote]);
});
testWidgets('scripts view reorders a script by long-press drag', (
tester,
) async {
final scripts = List.generate(
3,
(index) => Script(
id: index + 1,
label: 'Script $index',
lastUpdateTime: DateTime(2026),
),
);
final notifier = _TestScripts(scripts);
final container = _containerFor(
tester,
overrides: [scriptsProvider.overrideWith(() => notifier)],
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(child: ScriptsView()),
),
);
await tester.pump();
final from = tester.getCenter(find.text('Script 0'));
final to = tester.getCenter(find.text('Script 2'));
final gesture = await tester.startGesture(from);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
await gesture.moveTo(to + const Offset(0, 100));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(notifier.orders, [(0, 2)]);
});
testWidgets('scripts view renders an empty state without scripts', (

Some files were not shown because too many files have changed in this diff Show More