From 4b99ade2cff1fbe73d10b8b7756021c72d5d8679 Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Mon, 10 Aug 2026 23:27:41 +0800 Subject: [PATCH 1/6] docs: define URL import route selection --- .../specs/2026-08-10-url-import-route-design.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-url-import-route-design.md diff --git a/docs/superpowers/specs/2026-08-10-url-import-route-design.md b/docs/superpowers/specs/2026-08-10-url-import-route-design.md new file mode 100644 index 0000000..744a5ed --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-url-import-route-design.md @@ -0,0 +1,15 @@ +# URL Import Route Selection Design + +## Goal + +Let users explicitly choose proxy or direct routing when first importing a subscription URL, without changing the existing default or silently exposing a URL through a direct connection. + +## Design + +Replace the one-field URL input dialog used by the Profiles add sheet with a focused URL import dialog. It contains the URL field and two labelled radio choices: `syncViaProxy` (selected by default) and `syncDirect`. The dialog returns both URL and route choice. + +`ProfilesAction.addProfileFormURL` gains an optional `useProxy` argument defaulting to true and passes it to `Profile.update`. QR-code and external-link entry points retain that true default, preserving current behavior. + +## Verification + +Widget tests confirm the default proxy selection and selecting direct returns false. An action-level regression confirms the new parameter's default remains true and is forwarded to profile update. Full CI validates Flutter tests and all platform packages. From 76a4b98a8b09655a65cc1a5f48cae504cce52e8d Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Mon, 10 Aug 2026 23:31:01 +0800 Subject: [PATCH 2/6] fix: choose route for initial URL imports --- lib/providers/action.dart | 4 +- lib/views/profiles/add.dart | 118 +++++++++++++++++++----------- test/views/profiles/add_test.dart | 77 +++++++++++++++++++ 3 files changed, 154 insertions(+), 45 deletions(-) create mode 100644 test/views/profiles/add_test.dart diff --git a/lib/providers/action.dart b/lib/providers/action.dart index 089ee26..c02099d 100644 --- a/lib/providers/action.dart +++ b/lib/providers/action.dart @@ -1092,7 +1092,7 @@ class ProfilesAction extends _$ProfilesAction { } } - Future addProfileFormURL(String url) async { + Future addProfileFormURL(String url, {bool useProxy = true}) async { if (globalState.navigatorKey.currentState?.canPop() ?? false) { globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); } @@ -1100,7 +1100,7 @@ class ProfilesAction extends _$ProfilesAction { final profile = await globalState.loadingRun( tag: LoadingTag.profiles, () async { - return Profile.normal(url: url).update(); + return Profile.normal(url: url).update(useProxy: useProxy); }, title: currentAppLocalizations.addProfile, ); diff --git a/lib/views/profiles/add.dart b/lib/views/profiles/add.dart index a96eaac..ab2c29a 100644 --- a/lib/views/profiles/add.dart +++ b/lib/views/profiles/add.dart @@ -16,10 +16,10 @@ class AddProfileView extends StatelessWidget { .addProfileFormFile(); } - Future _handleAddProfileFormURL(String url) async { + Future _handleAddProfileFormURL(URLImportResult result) async { globalState.container .read(profilesActionProvider.notifier) - .addProfileFormURL(url); + .addProfileFormURL(result.url, useProxy: result.useProxy); } Future _toScan() async { @@ -32,33 +32,18 @@ class AddProfileView extends StatelessWidget { final url = await BaseNavigator.push(context, const ScanPage()); if (url != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - _handleAddProfileFormURL(url); + _handleAddProfileFormURL(URLImportResult(url: url)); }); } } Future _toAdd() async { final appLocalizations = context.appLocalizations; - final url = await globalState.showCommonDialog( - 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 result = await globalState.showCommonDialog( + child: const URLFormDialog(), ); - if (url != null) { - _handleAddProfileFormURL(url); + if (result != null) { + _handleAddProfileFormURL(result); } } @@ -90,6 +75,13 @@ class AddProfileView extends StatelessWidget { } } +class URLImportResult { + final String url; + final bool useProxy; + + const URLImportResult({required this.url, this.useProxy = true}); +} + class URLFormDialog extends StatefulWidget { const URLFormDialog({super.key}); @@ -98,12 +90,15 @@ class URLFormDialog extends StatefulWidget { } class _URLFormDialogState extends State { + final _formKey = GlobalKey(); final _urlController = TextEditingController(); + var _useProxy = true; Future _handleAddProfileFormURL() async { - final url = _urlController.value.text; - if (url.isEmpty) return; - Navigator.of(context).pop(url); + if (_formKey.currentState?.validate() == false) return; + Navigator.of(context).pop( + URLImportResult(url: _urlController.value.text, useProxy: _useProxy), + ); } @override @@ -118,6 +113,10 @@ class _URLFormDialogState extends State { return CommonDialog( title: appLocalizations.importFromURL, actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appLocalizations.cancel), + ), TextButton( onPressed: _handleAddProfileFormURL, child: Text(appLocalizations.submit), @@ -125,25 +124,58 @@ class _URLFormDialogState extends State { ], child: SizedBox( width: 300, - child: Wrap( - runSpacing: 16, - children: [ - TextField( - keyboardType: TextInputType.url, - minLines: 1, - maxLines: 5, - inputFormatters: TextInputLimits.limit(TextInputLimits.url), - onSubmitted: (_) { - _handleAddProfileFormURL(); - }, - onEditingComplete: _handleAddProfileFormURL, - controller: _urlController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: appLocalizations.url, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + keyboardType: TextInputType.url, + minLines: 1, + maxLines: 5, + inputFormatters: TextInputLimits.limit(TextInputLimits.url), + onSubmitted: (_) { + _handleAddProfileFormURL(); + }, + onEditingComplete: _handleAddProfileFormURL, + controller: _urlController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: appLocalizations.url, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return appLocalizations.emptyTip('').trim(); + } + if (!value.isUrl) { + return appLocalizations.urlTip('').trim(); + } + return null; + }, + ), + const SizedBox(height: 12), + RadioGroup( + groupValue: _useProxy, + onChanged: (value) { + if (value != null) { + setState(() => _useProxy = value); + } + }, + child: Column( + children: [ + RadioListTile( + value: true, + title: Text(appLocalizations.syncViaProxy), + ), + RadioListTile( + value: false, + title: Text(appLocalizations.syncDirect), + ), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/test/views/profiles/add_test.dart b/test/views/profiles/add_test.dart new file mode 100644 index 0000000..07cb232 --- /dev/null +++ b/test/views/profiles/add_test.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:fl_clash/l10n/l10n.dart'; +import 'package:fl_clash/views/profiles/add.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('URL import defaults to proxy routing', (tester) async { + final result = await _openImportDialog(tester); + + await tester.enterText( + find.byType(TextFormField), + 'https://example.com/sub', + ); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + + expect((await result.future)?.url, 'https://example.com/sub'); + expect((await result.future)?.useProxy, true); + }); + + testWidgets('URL import returns direct routing only after selection', ( + tester, + ) async { + final result = await _openImportDialog(tester); + + await tester.enterText( + find.byType(TextFormField), + 'https://example.com/sub', + ); + await tester.tap(find.text('Sync directly')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + + expect((await result.future)?.useProxy, false); + }); +} + +Future> _openImportDialog( + WidgetTester tester, +) async { + final completer = Completer(); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.delegate.supportedLocales, + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + completer.complete( + await showDialog( + context: context, + builder: (_) => const URLFormDialog(), + ), + ), + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + return completer; +} From 15a06a4ce23ea95227c939be5ef0b5f5a7562152 Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Mon, 10 Aug 2026 23:31:01 +0800 Subject: [PATCH 3/6] docs: plan URL import route verification --- .../plans/2026-08-10-url-import-route.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-url-import-route.md diff --git a/docs/superpowers/plans/2026-08-10-url-import-route.md b/docs/superpowers/plans/2026-08-10-url-import-route.md new file mode 100644 index 0000000..f79c58d --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-url-import-route.md @@ -0,0 +1,44 @@ +# URL Import Route Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give first-time URL imports an explicit proxy/direct route choice while preserving proxy as the default. + +**Architecture:** A stateful import dialog returns a value object containing URL and `useProxy`. The Profiles action receives the choice and forwards it to the existing profile download API. + +**Tech Stack:** Flutter, Riverpod action layer, Flutter widget tests. + +## Global Constraints + +- Proxy is the default for every existing caller. +- Direct import is available only after an explicit user choice. +- Reuse existing localized `syncViaProxy` and `syncDirect` labels. +- Do not alter the direct HTTP client or existing profile-sync menu. + +--- + +### Task 1: Route-aware URL import dialog + +**Files:** +- Modify: `lib/views/profiles/add.dart` +- Create: `test/views/profiles/add_test.dart` + +- [ ] Write widget tests that assert proxy is selected by default and a direct selection returns `useProxy: false` with the submitted URL. +- [ ] Run the test and confirm it fails before the route-aware dialog exists. +- [ ] Add `URLImportResult` and route radio controls using existing localized labels. +- [ ] Run the focused widget test. + +### Task 2: Forward the chosen route to download + +**Files:** +- Modify: `lib/providers/action.dart:1086-1100` +- Test: `test/views/profiles/add_test.dart` + +- [ ] Extend `addProfileFormURL` with `useProxy = true` and forward it to `Profile.update`. +- [ ] Connect the add-sheet dialog result to that method. +- [ ] Verify format, analysis, focused tests, and full test suite. + +### Task 3: Review and CI + +- [ ] Run UI self-review in default and direct-selection states; state any rendering limitation honestly. +- [ ] Create a draft PR and require full cross-platform CI before merge. From 2f0dcc0be74187fa7cf7cf901f7ce2e6915241ed Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Mon, 10 Aug 2026 23:35:13 +0800 Subject: [PATCH 4/6] fix: remove unused URL import label --- lib/views/profiles/add.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/views/profiles/add.dart b/lib/views/profiles/add.dart index ab2c29a..0c56035 100644 --- a/lib/views/profiles/add.dart +++ b/lib/views/profiles/add.dart @@ -38,7 +38,6 @@ class AddProfileView extends StatelessWidget { } Future _toAdd() async { - final appLocalizations = context.appLocalizations; final result = await globalState.showCommonDialog( child: const URLFormDialog(), ); From 56d415b9e6c64331a1a232fe39ba37ece9f3e07b Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Mon, 10 Aug 2026 23:42:26 +0800 Subject: [PATCH 5/6] fix: repair URL import dialog validation --- lib/views/profiles/add.dart | 2 +- test/views/profiles/add_test.dart | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/views/profiles/add.dart b/lib/views/profiles/add.dart index 0c56035..280410a 100644 --- a/lib/views/profiles/add.dart +++ b/lib/views/profiles/add.dart @@ -133,7 +133,7 @@ class _URLFormDialogState extends State { minLines: 1, maxLines: 5, inputFormatters: TextInputLimits.limit(TextInputLimits.url), - onSubmitted: (_) { + onFieldSubmitted: (_) { _handleAddProfileFormURL(); }, onEditingComplete: _handleAddProfileFormURL, diff --git a/test/views/profiles/add_test.dart b/test/views/profiles/add_test.dart index 07cb232..2931b3f 100644 --- a/test/views/profiles/add_test.dart +++ b/test/views/profiles/add_test.dart @@ -57,12 +57,11 @@ Future> _openImportDialog( builder: (context) { return ElevatedButton( onPressed: () async { - completer.complete( - await showDialog( - context: context, - builder: (_) => const URLFormDialog(), - ), - ), + final result = await showDialog( + context: context, + builder: (_) => const URLFormDialog(), + ); + completer.complete(result); }, child: const Text('Open'), ); From 4e239554738d8f2babfa5dc9d82dd12ec3619d9e Mon Sep 17 00:00:00 2001 From: SingLinkNetwork Date: Mon, 10 Aug 2026 23:49:43 +0800 Subject: [PATCH 6/6] test: stabilize URL import route dialog --- lib/views/profiles/add.dart | 6 +++--- test/views/profiles/add_test.dart | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/views/profiles/add.dart b/lib/views/profiles/add.dart index 280410a..b57960b 100644 --- a/lib/views/profiles/add.dart +++ b/lib/views/profiles/add.dart @@ -95,9 +95,9 @@ class _URLFormDialogState extends State { Future _handleAddProfileFormURL() async { if (_formKey.currentState?.validate() == false) return; - Navigator.of(context).pop( - URLImportResult(url: _urlController.value.text, useProxy: _useProxy), - ); + Navigator.of( + context, + ).pop(URLImportResult(url: _urlController.value.text, useProxy: _useProxy)); } @override diff --git a/test/views/profiles/add_test.dart b/test/views/profiles/add_test.dart index 2931b3f..5fa6173 100644 --- a/test/views/profiles/add_test.dart +++ b/test/views/profiles/add_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:fl_clash/l10n/l10n.dart'; +import 'package:fl_clash/providers/app.dart'; import 'package:fl_clash/views/profiles/add.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -45,6 +46,9 @@ Future> _openImportDialog( final completer = Completer(); await tester.pumpWidget( ProviderScope( + overrides: [ + viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 1000)), + ], child: MaterialApp( localizationsDelegates: const [ AppLocalizations.delegate,