diff --git a/ui/flutter/android/app/src/main/AndroidManifest.xml b/ui/flutter/android/app/src/main/AndroidManifest.xml
index e349bd1f..56b8329c 100644
--- a/ui/flutter/android/app/src/main/AndroidManifest.xml
+++ b/ui/flutter/android/app/src/main/AndroidManifest.xml
@@ -27,6 +27,30 @@
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
+
+
+
+
+
+
+
+
+
+
@@ -83,18 +107,6 @@
-
-
-
-
-
-
-
@@ -102,6 +114,10 @@
+
+
@@ -116,4 +132,4 @@
android:name="flutterEmbedding"
android:value="2" />
-
\ No newline at end of file
+
diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt
new file mode 100644
index 00000000..6dc1497b
--- /dev/null
+++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt
@@ -0,0 +1,31 @@
+package com.gopeed.gopeed
+
+import android.os.Bundle
+import android.view.Gravity
+import android.view.WindowManager
+import io.flutter.embedding.android.FlutterActivityLaunchConfigs
+
+class CreateDialogActivity : MainActivity() {
+ override fun isDialogMode(): Boolean = true
+
+ override fun getBackgroundMode(): FlutterActivityLaunchConfigs.BackgroundMode {
+ return FlutterActivityLaunchConfigs.BackgroundMode.transparent
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val metrics = resources.displayMetrics
+ val width = (metrics.widthPixels * 0.92f).toInt()
+ val maxHeight = (metrics.heightPixels * 0.62f).toInt()
+ val preferredHeight = (420 * metrics.density).toInt()
+
+ window.setGravity(Gravity.CENTER)
+ window.setDimAmount(0.28f)
+ window.setLayout(width, minOf(maxHeight, preferredHeight))
+
+ val attrs = window.attributes
+ attrs.flags = attrs.flags or WindowManager.LayoutParams.FLAG_DIM_BEHIND
+ window.attributes = attrs
+ }
+}
diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt
index 51a5eb01..d054f1da 100644
--- a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt
+++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt
@@ -7,9 +7,11 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMethodCodec
-class MainActivity : FlutterActivity() {
+open class MainActivity : FlutterActivity() {
private val CHANNEL = "gopeed.com/libgopeed"
+ protected open fun isDialogMode(): Boolean = false
+
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val taskQueue =
@@ -34,6 +36,9 @@ class MainActivity : FlutterActivity() {
Libgopeed.stop()
result.success(null)
}
+ "isDialogMode" -> {
+ result.success(isDialogMode())
+ }
else -> {
result.notImplemented()
}
diff --git a/ui/flutter/android/app/src/main/res/values-night/styles.xml b/ui/flutter/android/app/src/main/res/values-night/styles.xml
index 06952be7..7c601a8b 100644
--- a/ui/flutter/android/app/src/main/res/values-night/styles.xml
+++ b/ui/flutter/android/app/src/main/res/values-night/styles.xml
@@ -15,4 +15,18 @@
+
+
diff --git a/ui/flutter/android/app/src/main/res/values/styles.xml b/ui/flutter/android/app/src/main/res/values/styles.xml
index cb1ef880..36c4b265 100644
--- a/ui/flutter/android/app/src/main/res/values/styles.xml
+++ b/ui/flutter/android/app/src/main/res/values/styles.xml
@@ -15,4 +15,18 @@
+
+
diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
index 2bafbd26..58851a11 100644
--- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
+++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart
@@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:get/get.dart';
import 'package:launch_at_startup/launch_at_startup.dart';
@@ -69,6 +70,7 @@ class PendingUpdateTask {
class AppController extends GetxController with WindowListener, TrayListener {
static StartConfig? _defaultStartConfig;
+ static const _nativeChannel = MethodChannel('gopeed.com/libgopeed');
/// Command line --hidden flag passed from main.dart
final bool hiddenFromArgs;
@@ -79,6 +81,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
final startConfig = StartConfig().obs;
final runningPort = 0.obs;
final downloaderConfig = DownloaderConfig().obs;
+ var androidDialogMode = false;
/// The task that is pending URL update via listen mode.
/// Stored here in AppController to persist across page navigations.
@@ -124,9 +127,11 @@ class AppController extends GetxController with WindowListener, TrayListener {
void onClose() {
_linkSubscription?.cancel();
trayManager.removeListener(this);
- HostRpcService.instance.stop();
- WebViewRpcService.instance.stop();
- LibgopeedBoot.instance.stop();
+ if (!androidDialogMode) {
+ HostRpcService.instance.stop();
+ WebViewRpcService.instance.stop();
+ LibgopeedBoot.instance.stop();
+ }
}
@override
@@ -182,6 +187,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
// For web, just show window
return;
}
+ androidDialogMode = await _isAndroidDialogMode();
// Handle deep link
_appLinks = AppLinks();
@@ -241,6 +247,18 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
}
+ Future _isAndroidDialogMode() async {
+ if (!Util.isAndroid()) {
+ return false;
+ }
+ try {
+ return await _nativeChannel.invokeMethod('isDialogMode') ?? false;
+ } catch (e) {
+ logger.w("get android dialog mode fail", e);
+ return false;
+ }
+ }
+
Future _initWindows() async {
if (!Util.isDesktop()) {
return;
@@ -440,9 +458,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
} else {
path = (await toFile(uri.toString())).path;
}
- Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
- arguments: RedirectArgs(Routes.CREATE,
- arguments: CreateTask(req: Request(url: path))));
+ _handleToCreate0(CreateTask(req: Request(url: path)));
}
String runningAddress() {
@@ -697,8 +713,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
}
_handleToCreate0(CreateTask createTaskParams) {
+ final targetRoute =
+ androidDialogMode ? Routes.QUICK_CREATE : Routes.CREATE;
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
- arguments: RedirectArgs(Routes.CREATE, arguments: createTaskParams));
+ arguments: RedirectArgs(targetRoute, arguments: createTaskParams));
}
_handleToExtension(String params) {
diff --git a/ui/flutter/lib/app/modules/create/views/create_dialog_view.dart b/ui/flutter/lib/app/modules/create/views/create_dialog_view.dart
new file mode 100644
index 00000000..4d5832e2
--- /dev/null
+++ b/ui/flutter/lib/app/modules/create/views/create_dialog_view.dart
@@ -0,0 +1,299 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:get/get.dart';
+import 'package:path/path.dart' as path;
+
+import '../../../../api/api.dart';
+import '../../../../api/model/create_task.dart';
+import '../../../../api/model/create_task_batch.dart';
+import '../../../../api/model/options.dart';
+import '../../../../api/model/resolve_result.dart';
+import '../../../../api/model/resolve_task.dart';
+import '../../../../util/message.dart';
+import '../../../views/directory_selector.dart';
+import '../../app/controllers/app_controller.dart';
+
+class CreateDialogView extends StatefulWidget {
+ const CreateDialogView({Key? key}) : super(key: key);
+
+ @override
+ State createState() => _CreateDialogViewState();
+}
+
+class _CreateDialogViewState extends State {
+ final _pathController = TextEditingController();
+ final _nameController = TextEditingController();
+
+ CreateTask? _createTask;
+ ResolveResult? _resolveResult;
+ Object? _error;
+ bool _resolving = true;
+ bool _creating = false;
+
+ @override
+ void initState() {
+ super.initState();
+ final appController = Get.find();
+ _pathController.text = renderPathPlaceholders(
+ appController.downloaderConfig.value.downloadDir,
+ );
+ WidgetsBinding.instance.addPostFrameCallback((_) => _loadArguments());
+ }
+
+ @override
+ void dispose() {
+ _pathController.dispose();
+ _nameController.dispose();
+ super.dispose();
+ }
+
+ Future _loadArguments() async {
+ final args = Get.rootDelegate.arguments();
+ if (args is! CreateTask || args.req?.url.isNotEmpty != true) {
+ if (mounted) {
+ setState(() {
+ _resolving = false;
+ });
+ }
+ return;
+ }
+
+ _createTask = args;
+ if (args.opts?.path.isNotEmpty == true) {
+ _pathController.text = args.opts!.path;
+ }
+ if (args.opts?.name.isNotEmpty == true) {
+ _nameController.text = args.opts!.name;
+ } else {
+ _nameController.text = _fallbackName(args.req!.url);
+ }
+
+ await _resolve(args);
+ }
+
+ Future _resolve(CreateTask createTask) async {
+ setState(() {
+ _resolving = true;
+ _error = null;
+ });
+
+ try {
+ final opt = Options(
+ name: createTask.opts?.name ?? '',
+ path: _pathController.text,
+ selectFiles: const [],
+ extra: _optsExtra(),
+ );
+ final result = await resolve(
+ ResolveTask(req: createTask.req!, opts: opt),
+ );
+ if (!mounted) return;
+ setState(() {
+ _resolveResult = result;
+ if (_nameController.text.trim().isEmpty ||
+ _nameController.text == _fallbackName(createTask.req!.url)) {
+ _nameController.text = _resolvedName(result, createTask.req!.url);
+ }
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = e;
+ });
+ } finally {
+ if (mounted) {
+ setState(() {
+ _resolving = false;
+ });
+ }
+ }
+ }
+
+ Future _confirm() async {
+ final createTaskParams = _createTask;
+ if (createTaskParams?.req == null) {
+ return;
+ }
+
+ setState(() {
+ _creating = true;
+ });
+
+ try {
+ final rr = _resolveResult;
+ final optExtra = _optsExtra();
+ if (rr == null) {
+ await createTask(
+ CreateTask(
+ req: createTaskParams!.req,
+ opts: Options(
+ name: _nameController.text.trim(),
+ path: _pathController.text,
+ selectFiles: const [],
+ extra: optExtra,
+ ),
+ ),
+ );
+ } else if (rr.id.isEmpty) {
+ final reqs = rr.res.files.map((file) {
+ return CreateTaskBatchItem(
+ req: file.req ?? createTaskParams!.req,
+ opts: Options(
+ name: file.name,
+ path: path.join(_pathController.text, rr.res.name, file.path),
+ selectFiles: const [],
+ extra: optExtra,
+ ),
+ );
+ }).toList();
+ await createTaskBatch(CreateTaskBatch(reqs: reqs));
+ } else {
+ await createTask(
+ CreateTask(
+ rid: rr.id,
+ opts: Options(
+ name: _nameController.text.trim(),
+ path: _pathController.text,
+ selectFiles: rr.res.files.asMap().keys.toList(),
+ extra: optExtra,
+ ),
+ ),
+ );
+ }
+ await SystemNavigator.pop();
+ } catch (e) {
+ showErrorMessage(e);
+ if (mounted) {
+ setState(() {
+ _creating = false;
+ });
+ }
+ }
+ }
+
+ Object _optsExtra() {
+ final httpConfig =
+ Get.find().downloaderConfig.value.protocolConfig.http;
+ return OptsExtraHttp(connections: httpConfig.connections);
+ }
+
+ String _resolvedName(ResolveResult result, String url) {
+ if (result.res.name.trim().isNotEmpty) {
+ return result.res.name;
+ }
+ if (result.res.files.length == 1) {
+ return result.res.files.first.name;
+ }
+ return _fallbackName(url);
+ }
+
+ String _fallbackName(String url) {
+ final uri = Uri.tryParse(url);
+ final uriName = uri == null ? '' : path.basename(uri.path);
+ if (uriName.isNotEmpty && uriName != '.') {
+ return uriName;
+ }
+ return url;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Scaffold(
+ backgroundColor: Colors.transparent,
+ body: SafeArea(
+ child: Center(
+ child: Material(
+ color: theme.colorScheme.surface,
+ elevation: 16,
+ borderRadius: BorderRadius.circular(8),
+ clipBehavior: Clip.antiAlias,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 420),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'create'.tr,
+ style: theme.textTheme.titleMedium,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: _creating ? null : SystemNavigator.pop,
+ tooltip: 'cancel'.tr,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ if (_resolving)
+ const LinearProgressIndicator(minHeight: 2)
+ else
+ const SizedBox(height: 2),
+ const SizedBox(height: 14),
+ TextField(
+ controller: _nameController,
+ enabled: !_creating,
+ decoration: InputDecoration(
+ labelText: 'rename'.tr,
+ prefixIcon: const Icon(Icons.insert_drive_file),
+ ),
+ maxLines: 1,
+ ),
+ const SizedBox(height: 12),
+ DirectorySelector(
+ controller: _pathController,
+ showAndoirdToggle: true,
+ allowEdit: true,
+ ),
+ if (_error != null) ...[
+ const SizedBox(height: 12),
+ Text(
+ _error.toString(),
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.error,
+ ),
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ const SizedBox(height: 18),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ TextButton(
+ onPressed: _creating ? null : SystemNavigator.pop,
+ child: Text('cancel'.tr),
+ ),
+ const SizedBox(width: 8),
+ ElevatedButton(
+ onPressed:
+ (_resolving || _creating) ? null : _confirm,
+ child: _creating
+ ? const SizedBox.square(
+ dimension: 18,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ )
+ : Text('download'.tr),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/ui/flutter/lib/app/routes/app_pages.dart b/ui/flutter/lib/app/routes/app_pages.dart
index f3965d4d..9b890179 100644
--- a/ui/flutter/lib/app/routes/app_pages.dart
+++ b/ui/flutter/lib/app/routes/app_pages.dart
@@ -1,6 +1,7 @@
import 'package:get/get.dart';
import '../modules/create/bindings/create_binding.dart';
+import '../modules/create/views/create_dialog_view.dart';
import '../modules/create/views/create_view.dart';
import '../modules/extension/bindings/extension_binding.dart';
import '../modules/extension/views/extension_view.dart';
@@ -78,6 +79,12 @@ class AppPages {
page: () => CreateView(),
binding: CreateBinding(),
),
+ GetPage(
+ name: _Paths.QUICK_CREATE,
+ transition: Transition.noTransition,
+ page: () => const CreateDialogView(),
+ binding: CreateBinding(),
+ ),
GetPage(
name: _Paths.REDIRECT,
page: () => const RedirectView(),
diff --git a/ui/flutter/lib/app/routes/app_routes.dart b/ui/flutter/lib/app/routes/app_routes.dart
index 17599085..40b7b4b8 100644
--- a/ui/flutter/lib/app/routes/app_routes.dart
+++ b/ui/flutter/lib/app/routes/app_routes.dart
@@ -7,6 +7,7 @@ abstract class Routes {
static const ROOT = _Paths.ROOT;
static const HOME = _Paths.HOME;
static const CREATE = _Paths.CREATE;
+ static const QUICK_CREATE = _Paths.QUICK_CREATE;
static const LOGIN = _Paths.LOGIN;
static const TASK = _Paths.HOME + _Paths.TASK;
static const TASK_FILES = TASK + _Paths.TASK_FILES;
@@ -21,6 +22,7 @@ abstract class _Paths {
static const ROOT = '/';
static const HOME = '/home';
static const CREATE = '/create';
+ static const QUICK_CREATE = '/quick-create';
static const LOGIN = '/login';
static const TASK = '/task';
static const TASK_FILES = '/files';