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 00000000..f79c58d2 --- /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. 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 00000000..744a5ed8 --- /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. diff --git a/lib/providers/action.dart b/lib/providers/action.dart index 089ee265..c02099dc 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 a96eaaca..b57960ba 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,17 @@ 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 +74,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 +89,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 +112,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 +123,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), + onFieldSubmitted: (_) { + _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 00000000..5fa6173d --- /dev/null +++ b/test/views/profiles/add_test.dart @@ -0,0 +1,80 @@ +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'; +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( + overrides: [ + viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 1000)), + ], + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.delegate.supportedLocales, + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (_) => const URLFormDialog(), + ); + completer.complete(result); + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + return completer; +}