Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/superpowers/plans/2026-08-10-url-import-route.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/superpowers/specs/2026-08-10-url-import-route-design.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions lib/providers/action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1092,15 +1092,15 @@ class ProfilesAction extends _$ProfilesAction {
}
}

Future<void> addProfileFormURL(String url) async {
Future<void> addProfileFormURL(String url, {bool useProxy = true}) async {
if (globalState.navigatorKey.currentState?.canPop() ?? false) {
globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst);
}
ref.read(currentPageLabelProvider.notifier).value = PageLabel.profiles;
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,
);
Expand Down
119 changes: 75 additions & 44 deletions lib/views/profiles/add.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ class AddProfileView extends StatelessWidget {
.addProfileFormFile();
}

Future<void> _handleAddProfileFormURL(String url) async {
Future<void> _handleAddProfileFormURL(URLImportResult result) async {
globalState.container
.read(profilesActionProvider.notifier)
.addProfileFormURL(url);
.addProfileFormURL(result.url, useProxy: result.useProxy);
}

Future<void> _toScan() async {
Expand All @@ -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<void> _toAdd() async {
final appLocalizations = context.appLocalizations;
final url = await globalState.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 result = await globalState.showCommonDialog<URLImportResult>(
child: const URLFormDialog(),
);
if (url != null) {
_handleAddProfileFormURL(url);
if (result != null) {
_handleAddProfileFormURL(result);
}
}

Expand Down Expand Up @@ -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});

Expand All @@ -98,12 +89,15 @@ class URLFormDialog extends StatefulWidget {
}

class _URLFormDialogState extends State<URLFormDialog> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
var _useProxy = true;

Future<void> _handleAddProfileFormURL() async {
final url = _urlController.value.text;
if (url.isEmpty) return;
Navigator.of(context).pop<String>(url);
if (_formKey.currentState?.validate() == false) return;
Navigator.of(
context,
).pop(URLImportResult(url: _urlController.value.text, useProxy: _useProxy));
}

@override
Expand All @@ -118,32 +112,69 @@ class _URLFormDialogState extends State<URLFormDialog> {
return CommonDialog(
title: appLocalizations.importFromURL,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appLocalizations.cancel),
),
TextButton(
onPressed: _handleAddProfileFormURL,
child: Text(appLocalizations.submit),
),
],
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<bool>(
groupValue: _useProxy,
onChanged: (value) {
if (value != null) {
setState(() => _useProxy = value);
}
},
child: Column(
children: [
RadioListTile<bool>(
value: true,
title: Text(appLocalizations.syncViaProxy),
),
RadioListTile<bool>(
value: false,
title: Text(appLocalizations.syncDirect),
),
],
),
),
),
],
],
),
),
),
);
Expand Down
80 changes: 80 additions & 0 deletions test/views/profiles/add_test.dart
Original file line number Diff line number Diff line change
@@ -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<Completer<URLImportResult?>> _openImportDialog(
WidgetTester tester,
) async {
final completer = Completer<URLImportResult?>();
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<URLImportResult>(
context: context,
builder: (_) => const URLFormDialog(),
);
completer.complete(result);
},
child: const Text('Open'),
);
},
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
return completer;
}
Loading