mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
Merge branch 'main' into codex/fix-settings-script-path-picker
This commit is contained in:
52 files changed
+762
-144
No files matched your search
@@ -6,15 +6,19 @@
|
||||
// What it checks:
|
||||
// - Every ARB file uses the repository's canonical two-space JSON formatting.
|
||||
// - Every app_<locale>.arb file declares the matching @@locale value.
|
||||
// - Message values are not empty.
|
||||
// - Every locale has exactly the same message keys as app_en.arb.
|
||||
// - Every translation preserves the placeholder variables used by app_en.arb.
|
||||
// - Message values are not empty (warning only).
|
||||
// - Every locale has the same message keys as app_en.arb (warning only).
|
||||
// - Every translation preserves the placeholder variables used by app_en.arb
|
||||
// (warning only).
|
||||
//
|
||||
// Notes:
|
||||
// - This script does not judge translation quality.
|
||||
// - It does not generate localization code or fully parse ICU MessageFormat.
|
||||
// `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax
|
||||
// and generate app_localizations*.dart.
|
||||
// - The script exits with a failure only for malformed JSON, invalid ARB
|
||||
// formatting, or a mismatched @@locale value. Translation completeness is
|
||||
// reported as warnings; generation and analysis remain the compile-time gate.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -49,27 +53,24 @@ void main() {
|
||||
keys.where((key) => (messages[key] as String).trim().isEmpty).toList()
|
||||
..sort();
|
||||
if (empty.isNotEmpty) {
|
||||
stderr.writeln('${file.path}: empty messages $empty');
|
||||
failed = true;
|
||||
_warning('${file.path}: empty messages $empty');
|
||||
}
|
||||
if (file.path == templateFile.path) continue;
|
||||
final missing = templateKeys.difference(keys);
|
||||
final extra = keys.difference(templateKeys);
|
||||
if (missing.isNotEmpty || extra.isNotEmpty) {
|
||||
stderr.writeln(
|
||||
_warning(
|
||||
'${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}',
|
||||
);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
for (final key in templateKeys.intersection(keys)) {
|
||||
final expected = _placeholders(template[key] as String);
|
||||
final actual = _placeholders(messages[key] as String);
|
||||
if (!_sameSet(expected, actual)) {
|
||||
stderr.writeln(
|
||||
_warning(
|
||||
'${file.path}: $key placeholders $actual do not match $expected',
|
||||
);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,3 +103,5 @@ Set<String> _placeholders(String message) => RegExp(
|
||||
/// Compares sets without depending on iteration order.
|
||||
bool _sameSet(Set<String> left, Set<String> right) =>
|
||||
left.length == right.length && left.containsAll(right);
|
||||
|
||||
void _warning(String message) => stderr.writeln('warning: $message');
|
||||
@@ -79,7 +79,7 @@ class _GopeedAppState extends ConsumerState<GopeedApp> with WidgetsBindingObserv
|
||||
return shad.ShadcnApp.router(
|
||||
debugShowCheckedModeBanner: false,
|
||||
disableBrowserContextMenu: false,
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
title: 'Gopeed',
|
||||
theme: AppTheme.light(themeColor),
|
||||
darkTheme: AppTheme.dark(themeColor),
|
||||
materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor),
|
||||
|
||||
@@ -28,6 +28,13 @@ class AndroidForegroundService {
|
||||
),
|
||||
);
|
||||
|
||||
// Android 13+ requires runtime permission to show the service notification.
|
||||
// A denial only hides it from the notification drawer; the service can run.
|
||||
final permission = await FlutterForegroundTask.checkNotificationPermission();
|
||||
if (permission == NotificationPermission.denied) {
|
||||
await FlutterForegroundTask.requestNotificationPermission();
|
||||
}
|
||||
|
||||
if (await FlutterForegroundTask.isRunningService) {
|
||||
_throwOnFailure(await FlutterForegroundTask.restartService());
|
||||
return;
|
||||
|
||||
@@ -2,14 +2,14 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../../core/common/task_event.dart';
|
||||
import '../../core/libgopeed_boot.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../util/log_util.dart';
|
||||
import '../../util/util.dart';
|
||||
import 'app_runtime_controller.dart';
|
||||
|
||||
@@ -28,13 +28,19 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
||||
StreamSubscription<TaskEvent>? _taskEventSubscription;
|
||||
var _notificationId = 0;
|
||||
|
||||
@visibleForTesting
|
||||
Stream<TaskEvent> get taskEvents => LibgopeedBoot.instance.taskEvents;
|
||||
|
||||
@override
|
||||
Future<AppNotificationState> build() async {
|
||||
final runtime = ref.watch(appRuntimeControllerProvider).value;
|
||||
if (runtime == null || kIsWeb || !Util.isDesktop()) {
|
||||
return const AppNotificationState();
|
||||
}
|
||||
await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale));
|
||||
await _initNotifications(
|
||||
appLocalizationsFor(runtime.downloaderConfig.extra.locale),
|
||||
requestPermissions: runtime.downloaderConfig.extra.desktopNotification,
|
||||
);
|
||||
_listenTaskEvents();
|
||||
ref.onDispose(() {
|
||||
unawaited(_taskEventSubscription?.cancel());
|
||||
@@ -42,11 +48,13 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
||||
return const AppNotificationState(started: true);
|
||||
}
|
||||
|
||||
Future<void> _initNotifications(AppLocalizations locale) async {
|
||||
const darwin = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
Future<void> _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async {
|
||||
// The runtime is watched, so enabling notifications in settings also
|
||||
// requests authorization. macOS remembers previously granted/denied access.
|
||||
final darwin = DarwinInitializationSettings(
|
||||
requestAlertPermission: requestPermissions,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
requestSoundPermission: requestPermissions,
|
||||
);
|
||||
final linux = LinuxInitializationSettings(
|
||||
defaultActionName: locale.open,
|
||||
@@ -56,13 +64,27 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
||||
String? windowsIconPath;
|
||||
try {
|
||||
if (Util.isWindows()) {
|
||||
final byteData = await rootBundle.load('assets/icon/icon.ico');
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/notification_icon.ico');
|
||||
await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
|
||||
windowsIconPath = file.path;
|
||||
// Windows requires a file path for IconUri, so use the bundled PNG
|
||||
// beside the executable rather than copying it to another directory.
|
||||
final file = File(
|
||||
path.join(
|
||||
path.dirname(Platform.resolvedExecutable),
|
||||
'data',
|
||||
'flutter_assets',
|
||||
'assets',
|
||||
'icon',
|
||||
'icon_512.png',
|
||||
),
|
||||
);
|
||||
if (await file.exists()) {
|
||||
windowsIconPath = file.absolute.path;
|
||||
} else {
|
||||
logger.w('Windows notification icon not found: ${file.path}');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error, stackTrace) {
|
||||
logger.w('prepare Windows notification icon failed', error, stackTrace);
|
||||
}
|
||||
|
||||
final windows = WindowsInitializationSettings(
|
||||
appName: 'Gopeed',
|
||||
@@ -78,7 +100,7 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
||||
|
||||
void _listenTaskEvents() {
|
||||
_taskEventSubscription?.cancel();
|
||||
_taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async {
|
||||
_taskEventSubscription = taskEvents.listen((event) async {
|
||||
final runtime = ref.read(appRuntimeControllerProvider).value;
|
||||
if (runtime?.downloaderConfig.extra.desktopNotification == false) return;
|
||||
final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? '');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../features/tasks/application/task_list_navigation.dart';
|
||||
|
||||
import '../../features/extensions/presentation/pages/extensions_page.dart';
|
||||
import '../../features/extensions/presentation/pages/extension_details_page.dart';
|
||||
@@ -17,6 +19,13 @@ import 'shells/main_shell.dart';
|
||||
class AppRouter {
|
||||
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
static void showDownloadingTasks() {
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context == null || !context.mounted) return;
|
||||
ProviderScope.containerOf(context, listen: false).read(taskListNavigationProvider.notifier).showDownloading();
|
||||
GoRouter.of(context).go('/');
|
||||
}
|
||||
|
||||
static GoRouter build(WebAuthController webAuthController) {
|
||||
return GoRouter(
|
||||
navigatorKey: rootNavigatorKey,
|
||||
|
||||
@@ -16,12 +16,18 @@ import '../window/app_window_appearance.dart';
|
||||
import 'capability_rpc.dart';
|
||||
import 'gopeed_capability.dart';
|
||||
import 'storage_capability.dart';
|
||||
import 'app_navigation_capability.dart';
|
||||
import '../../app/router/app_router.dart';
|
||||
|
||||
class AppCapabilities {
|
||||
AppCapabilities(CapabilityInvoker invoker) : gopeed = GopeedService(invoker), storage = AppStorageService(invoker);
|
||||
AppCapabilities(CapabilityInvoker invoker)
|
||||
: gopeed = GopeedService(invoker),
|
||||
storage = AppStorageService(invoker),
|
||||
navigation = AppNavigationService(invoker);
|
||||
|
||||
final GopeedService gopeed;
|
||||
final AppStorageService storage;
|
||||
final AppNavigationService navigation;
|
||||
}
|
||||
|
||||
class LocalAppCapabilities {
|
||||
@@ -29,6 +35,10 @@ class LocalAppCapabilities {
|
||||
registry = CapabilityRegistry(codecs);
|
||||
_bindGopeed(registry);
|
||||
_bindStorage(registry);
|
||||
registry.bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||
AppRouter.showDownloadingTasks();
|
||||
return const RpcUnit();
|
||||
});
|
||||
capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'capability_rpc.dart';
|
||||
|
||||
abstract final class NavigationMethods {
|
||||
static const showDownloadingTasks = RpcMethod<RpcUnit, RpcUnit>('navigation.tasks.downloading');
|
||||
}
|
||||
|
||||
class AppNavigationService {
|
||||
const AppNavigationService(this._invoker);
|
||||
|
||||
final CapabilityInvoker _invoker;
|
||||
|
||||
Future<void> showDownloadingTasks() async {
|
||||
await _invoker.invoke(NavigationMethods.showDownloadingTasks, const RpcUnit());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class AppWindowBootstrap {
|
||||
await windowManager.ensureInitialized();
|
||||
|
||||
final windowOptions = WindowOptions(
|
||||
title: 'Gopeed',
|
||||
size: const Size(1024, 768),
|
||||
center: true,
|
||||
skipTaskbar: false,
|
||||
@@ -74,8 +75,8 @@ class AppWindowBootstrap {
|
||||
|
||||
static String subWindowTitle(AppWindowType type, AppLocalizations l10n) {
|
||||
return switch (type) {
|
||||
AppWindowType.createTask => '${l10n.create} - Gopeed',
|
||||
_ => l10n.appTitle,
|
||||
AppWindowType.createTask => 'Gopeed - ${l10n.create}',
|
||||
_ => 'Gopeed',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -633,6 +633,7 @@ class _Toolbar extends StatelessWidget {
|
||||
final search = AppTextField(
|
||||
key: const ValueKey('extension-search-input'),
|
||||
controller: searchController,
|
||||
padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null,
|
||||
placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)),
|
||||
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))],
|
||||
onSubmitted: onSearch,
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../../../shared/widgets/responsive_navigation_layout.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../tasks/application/tasks_controller.dart';
|
||||
import '../../../tasks/application/task_list_navigation.dart';
|
||||
import '../../../tasks/application/pending_update_task.dart';
|
||||
import '../../../tasks/application/task_batch_selection_controller.dart';
|
||||
import '../../../tasks/domain/task_record.dart';
|
||||
@@ -86,6 +87,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final tasksAsync = ref.watch(tasksControllerProvider);
|
||||
ref.listen(taskListNavigationProvider, (_, _) {
|
||||
_searchController.clear();
|
||||
_setActiveFilter(_TaskFilter.downloading);
|
||||
setState(() => _batchMode = false);
|
||||
});
|
||||
final tasksState = tasksAsync.value;
|
||||
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
|
||||
final filteredTasks = _filteredTasks(tasks);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final taskListNavigationProvider = NotifierProvider<TaskListNavigation, int>(TaskListNavigation.new);
|
||||
|
||||
// A request also resets an already-visible list's filter, search and selection.
|
||||
class TaskListNavigation extends Notifier<int> {
|
||||
@override
|
||||
int build() => 0;
|
||||
|
||||
void showDownloading() => state++;
|
||||
}
|
||||
@@ -29,9 +29,9 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
||||
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
import '../../../../shared/widgets/app_number_input.dart';
|
||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||
import '../../../../shared/widgets/app_primary_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
@@ -39,6 +39,7 @@ import '../../../../l10n/l10n.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../application/pending_create_task.dart';
|
||||
import '../widgets/resolve_file_tree.dart';
|
||||
import '../widgets/resolve_task_actions.dart';
|
||||
|
||||
class CreateTaskWindowPage extends ConsumerStatefulWidget {
|
||||
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
|
||||
@@ -87,6 +88,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
String _configuredDownloadDirectory = '';
|
||||
String _fileDataUri = '';
|
||||
bool _programmaticUrlChange = false;
|
||||
String _lastUrlText = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -172,10 +174,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
void _handleUrlChanged() {
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
_fileDataUri = '';
|
||||
final urlText = _urlController.text;
|
||||
if (urlText != _lastUrlText) {
|
||||
_lastUrlText = urlText;
|
||||
_renameController.clear();
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
_fileDataUri = '';
|
||||
}
|
||||
}
|
||||
_recognizeMagnetUri(_urlController.text.trim());
|
||||
_recognizeMagnetUri(urlText.trim());
|
||||
}
|
||||
|
||||
void _applyInitialTask(CreateTask? task) {
|
||||
@@ -220,9 +227,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
}
|
||||
if (opts != null) {
|
||||
if (opts.name.isNotEmpty) {
|
||||
_renameController.text = opts.name;
|
||||
}
|
||||
if (opts.path.isNotEmpty) {
|
||||
_directoryController.text = opts.path;
|
||||
}
|
||||
@@ -370,18 +374,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
const SizedBox(height: 16),
|
||||
_FormRow(
|
||||
label: context.l10n.connections,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: AppDesignTokens.settingsNumberControlWidth,
|
||||
child: AppNumberInput(
|
||||
fieldKey: const ValueKey('create-task-connections-input'),
|
||||
controller: _connectionsController,
|
||||
min: 1,
|
||||
max: 256,
|
||||
hintText: context.l10n.enterCount,
|
||||
),
|
||||
),
|
||||
child: AppNumberInput(
|
||||
fieldKey: const ValueKey('create-task-connections-input'),
|
||||
controller: _connectionsController,
|
||||
min: 1,
|
||||
max: 256,
|
||||
hintText: context.l10n.enterCount,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -396,10 +394,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
pickerKey: const ValueKey('create-task-directory-picker'),
|
||||
controller: _directoryController,
|
||||
hintText: context.l10n.chooseDownloadDirectory,
|
||||
filled: true,
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
onChanged: (_) {
|
||||
if (_asDefaultPath) {
|
||||
setState(() => _asDefaultPath = false);
|
||||
@@ -707,16 +701,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppPrimaryButton(
|
||||
onPressed: _creating ? null : _confirm,
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
child: Center(
|
||||
child: _creating
|
||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
|
||||
: Text(context.l10n.confirm),
|
||||
),
|
||||
),
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('create-task-confirm-button'),
|
||||
onPressed: _confirm,
|
||||
loading: _creating,
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.confirm))),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -737,6 +727,19 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
Future<void> _finishCreation() async {
|
||||
if (widget.windowController != null) {
|
||||
// Navigation is best-effort after a successful submission. A navigation
|
||||
// failure must not leave a completed form available for resubmission.
|
||||
try {
|
||||
await ref.read(appCapabilitiesProvider).navigation.showDownloadingTasks();
|
||||
} catch (error) {
|
||||
debugPrint('Unable to select downloading tasks: $error');
|
||||
}
|
||||
}
|
||||
await _closeWindow();
|
||||
}
|
||||
|
||||
Future<void> _loadDefaults() async {
|
||||
try {
|
||||
final config = await ref.read(gopeedServiceProvider).getConfig();
|
||||
@@ -863,7 +866,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
await _closeWindow();
|
||||
await _finishCreation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -871,7 +874,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final options = _buildOptions();
|
||||
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options));
|
||||
if (!mounted) return;
|
||||
_syncResolvedName(result);
|
||||
final created = await _showResolveDialog(request, result);
|
||||
if (!created) {
|
||||
if (mounted) {
|
||||
@@ -879,7 +881,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _closeWindow();
|
||||
await _finishCreation();
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
_showToast(error.toString());
|
||||
@@ -1156,19 +1158,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
SecondaryButton(
|
||||
onPressed: submitting ? null : () => closeOverlay(dialogContext, false),
|
||||
child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))),
|
||||
),
|
||||
AppPrimaryButton(
|
||||
onPressed: submitting ? null : submit,
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
child: Center(
|
||||
child: submitting
|
||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
|
||||
: Text(dialogContext.l10n.create),
|
||||
),
|
||||
flutter.Flexible(
|
||||
child: ResolveTaskActions(
|
||||
submitting: submitting,
|
||||
onCancel: () => closeOverlay(dialogContext, false),
|
||||
onCreate: submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1331,12 +1325,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
filterController.dispose();
|
||||
}
|
||||
|
||||
void _syncResolvedName(ResolveResult result) {
|
||||
if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) {
|
||||
_renameController.text = result.res.name;
|
||||
}
|
||||
}
|
||||
|
||||
void _setUrlText(String value, {String fileDataUri = ''}) {
|
||||
_programmaticUrlChange = true;
|
||||
_urlController.text = value;
|
||||
@@ -1678,17 +1666,12 @@ class _WindowTextField extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return AppTextField(
|
||||
controller: controller,
|
||||
hintText: hintText,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
obscureText: obscureText,
|
||||
filled: true,
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
|
||||
child: FileTreeView<int>(
|
||||
items: _treeItems,
|
||||
keyPrefix: 'resolve-tree',
|
||||
rowHeight: _resolveTreeRowHeight,
|
||||
contentTextStyle: _resolveFileNameStyle,
|
||||
headerLeading: ExcludeFocus(
|
||||
child: Checkbox(
|
||||
state: allSelected
|
||||
@@ -336,3 +338,5 @@ extension on _FileTypeFilter {
|
||||
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
|
||||
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
|
||||
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
|
||||
const _resolveTreeRowHeight = 36.0;
|
||||
const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25);
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:flutter/widgets.dart' as flutter;
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
|
||||
class ResolveTaskActions extends StatelessWidget {
|
||||
const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate});
|
||||
|
||||
final bool submitting;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onCreate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Size both actions from the wider translated label, including while the
|
||||
// create button replaces its label with a loader.
|
||||
return IntrinsicWidth(
|
||||
child: flutter.Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
flutter.Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
|
||||
child: SecondaryButton(
|
||||
key: const ValueKey('resolve-cancel-button'),
|
||||
onPressed: submitting ? null : onCancel,
|
||||
child: Text(context.l10n.cancel, maxLines: 1, softWrap: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
flutter.Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
|
||||
child: AppLoadingButton(
|
||||
key: const ValueKey('resolve-create-button'),
|
||||
onPressed: onCreate,
|
||||
loading: submitting,
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
child: Text(context.l10n.createAction, maxLines: 1, softWrap: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "خروج",
|
||||
"pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج",
|
||||
"create": "إنشاء مهمة",
|
||||
"createAction": "إنشاء",
|
||||
"directDownload": "تنزيل مباشر",
|
||||
"advancedOptions": "خيارات متقدمة",
|
||||
"followSettings": "اتباع الإعدادات",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Çıxış",
|
||||
"pressBackAgainToExit": "Çıxmaq üçün yenidən geri düyməsinə basın",
|
||||
"create": "Tapşırıq yarat",
|
||||
"createAction": "Yarat",
|
||||
"directDownload": "Birbaşa Endirmə",
|
||||
"advancedOptions": "Əlavə Seçimlər",
|
||||
"followSettings": "Parametrlərə uyğun",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Surt",
|
||||
"pressBackAgainToExit": "Torna a prémer enrere per sortir",
|
||||
"create": "Crea una tasca",
|
||||
"createAction": "Crea",
|
||||
"directDownload": "Baixada directa",
|
||||
"advancedOptions": "Opcions avançades",
|
||||
"followSettings": "Segueix la configuració",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Beenden",
|
||||
"pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden",
|
||||
"create": "Aufgabe Erstellen",
|
||||
"createAction": "Erstellen",
|
||||
"directDownload": "Direkter Download",
|
||||
"advancedOptions": "Erweiterte Einstellungen",
|
||||
"followSettings": "Einstellungen Folgen",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Exit",
|
||||
"pressBackAgainToExit": "Press back again to exit",
|
||||
"create": "Create Task",
|
||||
"createAction": "Create",
|
||||
"directDownload": "Direct Download",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"followSettings": "Follow Settings",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Salir",
|
||||
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
|
||||
"create": "Crear Tarea",
|
||||
"createAction": "Crear",
|
||||
"directDownload": "Descarga Directa",
|
||||
"advancedOptions": "Opciones Avanzadas",
|
||||
"followSettings": "Siga las opciones",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "خروج",
|
||||
"pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید",
|
||||
"create": "ایجاد کار",
|
||||
"createAction": "ایجاد",
|
||||
"directDownload": "دانلود مستقیم",
|
||||
"advancedOptions": "گزینه های پیشرفته",
|
||||
"followSettings": "تنظیمات را دنبال کنید",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Quitter",
|
||||
"pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter",
|
||||
"create": "Créer une tâche",
|
||||
"createAction": "Créer",
|
||||
"directDownload": "Téléchargement direct",
|
||||
"advancedOptions": "Options avancées",
|
||||
"followSettings": "Suivre les paramètres",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Kilépés",
|
||||
"pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez",
|
||||
"create": "Feladat létrehozása",
|
||||
"createAction": "Létrehozás",
|
||||
"directDownload": "Közvetlen letöltés",
|
||||
"advancedOptions": "Haladó beállítások",
|
||||
"followSettings": "Beállítások követése",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Keluar",
|
||||
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
|
||||
"create": "Membuat Tugas",
|
||||
"createAction": "Buat",
|
||||
"directDownload": "Unduhan Langsung",
|
||||
"advancedOptions": "Pengaturan Lanjutan",
|
||||
"followSettings": "Mengikuti Pengaturan",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Uscita",
|
||||
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
|
||||
"create": "Crea attività",
|
||||
"createAction": "Crea",
|
||||
"directDownload": "Scaricamento diretto",
|
||||
"advancedOptions": "Opzioni avanzate",
|
||||
"followSettings": "Seguire le impostazioni",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "終了",
|
||||
"pressBackAgainToExit": "もう一度戻るボタンを押すと終了します",
|
||||
"create": "タスクを作成",
|
||||
"createAction": "作成",
|
||||
"directDownload": "直接ダウンロード",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"followSettings": "設定に従う",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "종료",
|
||||
"pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요",
|
||||
"create": "작업 생성",
|
||||
"createAction": "생성",
|
||||
"directDownload": "바로 다운로드",
|
||||
"advancedOptions": "고급 옵션",
|
||||
"followSettings": "설정값 사용",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Wyjście",
|
||||
"pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść",
|
||||
"create": "Utwórz zadanie",
|
||||
"createAction": "Utwórz",
|
||||
"directDownload": "Pobieranie bezpośrednie",
|
||||
"advancedOptions": "Zaawansowane",
|
||||
"followSettings": "Podążaj za ustawieniami",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Sair",
|
||||
"pressBackAgainToExit": "Prima novamente Voltar para sair",
|
||||
"create": "Criar Tarefa",
|
||||
"createAction": "Criar",
|
||||
"directDownload": "Download Direto",
|
||||
"advancedOptions": "Opções Avançadas",
|
||||
"followSettings": "Seguir as Configurações",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Выход",
|
||||
"pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти",
|
||||
"create": "Создать задачу",
|
||||
"createAction": "Создать",
|
||||
"directDownload": "Прямая загрузка",
|
||||
"advancedOptions": "Расширенные опции",
|
||||
"followSettings": "По умолчанию",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "வெளியேறு",
|
||||
"pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்",
|
||||
"create": "பணியை உருவாக்கு",
|
||||
"createAction": "உருவாக்கு",
|
||||
"directDownload": "நேரடி பதிவிறக்கம்",
|
||||
"advancedOptions": "மேம்பட்ட விருப்பங்கள்",
|
||||
"followSettings": "அமைப்புகளைப் பின்தொடரவும்",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Çıkış",
|
||||
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın",
|
||||
"create": "İşlem Oluştur",
|
||||
"createAction": "Oluştur",
|
||||
"directDownload": "Direkt İndir",
|
||||
"advancedOptions": "Gelişmiş Seçenekler",
|
||||
"followSettings": "Ayarları Takip Et",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Вихід",
|
||||
"pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти",
|
||||
"create": "Створити завдання",
|
||||
"createAction": "Створити",
|
||||
"directDownload": "Пряме завантаження",
|
||||
"advancedOptions": "Додаткові параметри",
|
||||
"followSettings": "За замовчуванням",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "Thoát",
|
||||
"pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát",
|
||||
"create": "Tạo nhiệm vụ",
|
||||
"createAction": "Tạo",
|
||||
"directDownload": "Tải trực tiếp",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"followSettings": "Theo thiết lập",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "退出",
|
||||
"pressBackAgainToExit": "再按一次返回键退出应用",
|
||||
"create": "创建任务",
|
||||
"createAction": "创建",
|
||||
"directDownload": "直接下载",
|
||||
"advancedOptions": "高级选项",
|
||||
"followSettings": "跟随设置",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"exit": "退出",
|
||||
"pressBackAgainToExit": "再按一次返回鍵退出應用程式",
|
||||
"create": "建立任務",
|
||||
"createAction": "建立",
|
||||
"directDownload": "直接下載",
|
||||
"advancedOptions": "進階選項",
|
||||
"followSettings": "跟隨設定",
|
||||
|
||||
@@ -18,42 +18,50 @@ class AppComponentThemes extends StatelessWidget {
|
||||
align: 1,
|
||||
border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1),
|
||||
),
|
||||
child: shad.ComponentTheme<shad.SecondaryButtonTheme>(
|
||||
data: shad.SecondaryButtonTheme(
|
||||
decoration: (context, states, value) {
|
||||
final decoration = switch (value) {
|
||||
final BoxDecoration decoration => decoration,
|
||||
_ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)),
|
||||
};
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return decoration.copyWith(color: palette.surfaceSoft);
|
||||
}
|
||||
if (theme.brightness == Brightness.light) {
|
||||
final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft);
|
||||
final hoverBackground = Color.alphaBlend(
|
||||
palette.textPrimary.withValues(alpha: 0.13),
|
||||
palette.surfaceSoft,
|
||||
);
|
||||
return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
textStyle: (context, states, value) => states.contains(WidgetState.disabled)
|
||||
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
||||
: value,
|
||||
iconTheme: (context, states, value) => states.contains(WidgetState.disabled)
|
||||
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
||||
: value,
|
||||
child: shad.ComponentTheme<shad.TextFieldTheme>(
|
||||
data: shad.TextFieldTheme(
|
||||
filled: true,
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
padding: AppDesignTokens.textFieldPadding,
|
||||
),
|
||||
child: shad.ComponentTheme<shad.CheckboxTheme>(
|
||||
data: shad.CheckboxTheme(
|
||||
size: AppDesignTokens.checkboxSize,
|
||||
gap: AppDesignTokens.checkboxLabelGap,
|
||||
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border,
|
||||
child: shad.ComponentTheme<shad.SecondaryButtonTheme>(
|
||||
data: shad.SecondaryButtonTheme(
|
||||
decoration: (context, states, value) {
|
||||
final decoration = switch (value) {
|
||||
final BoxDecoration decoration => decoration,
|
||||
_ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)),
|
||||
};
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return decoration.copyWith(color: palette.surfaceSoft);
|
||||
}
|
||||
if (theme.brightness == Brightness.light) {
|
||||
final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft);
|
||||
final hoverBackground = Color.alphaBlend(
|
||||
palette.textPrimary.withValues(alpha: 0.13),
|
||||
palette.surfaceSoft,
|
||||
);
|
||||
return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
textStyle: (context, states, value) => states.contains(WidgetState.disabled)
|
||||
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
||||
: value,
|
||||
iconTheme: (context, states, value) => states.contains(WidgetState.disabled)
|
||||
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
||||
: value,
|
||||
),
|
||||
child: shad.ComponentTheme<shad.ToastTheme>(
|
||||
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)),
|
||||
child: child,
|
||||
child: shad.ComponentTheme<shad.CheckboxTheme>(
|
||||
data: shad.CheckboxTheme(
|
||||
size: AppDesignTokens.checkboxSize,
|
||||
gap: AppDesignTokens.checkboxLabelGap,
|
||||
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border,
|
||||
),
|
||||
child: shad.ComponentTheme<shad.ToastTheme>(
|
||||
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ class AppDesignTokens {
|
||||
static const double contentHeaderHeight = 48;
|
||||
static const double taskRowHeight = 72;
|
||||
static const double controlRadius = 4;
|
||||
static const double dialogActionMinWidth = 100;
|
||||
static const double checkboxSize = 18;
|
||||
static const double checkboxLabelGap = 10;
|
||||
static const double taskDetailsDrawerMinWidth = 440;
|
||||
@@ -21,6 +22,9 @@ class AppDesignTokens {
|
||||
static const double settingsFormControlWidth = 420;
|
||||
static const double settingsNumberControlWidth = 168;
|
||||
static const double settingsItemMinLabelWidth = 160;
|
||||
static const EdgeInsets textFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 11);
|
||||
static const EdgeInsets compactTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 6);
|
||||
static const EdgeInsets numberTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 10);
|
||||
|
||||
static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32);
|
||||
static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16);
|
||||
|
||||
@@ -30,7 +30,15 @@ class AppLoadingButton extends StatelessWidget {
|
||||
final palette = AppPalette.of(context);
|
||||
final replaceChild = loading && icon == null;
|
||||
final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon;
|
||||
final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child;
|
||||
final effectiveChild = replaceChild
|
||||
? Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ExcludeSemantics(child: Opacity(opacity: 0, child: child)),
|
||||
Positioned.fill(child: _LoadingIcon(variant: variant)),
|
||||
],
|
||||
)
|
||||
: child;
|
||||
final content = leading == null
|
||||
? effectiveChild
|
||||
: Row(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../theme/app_design_tokens.dart';
|
||||
import 'app_text_field.dart';
|
||||
|
||||
/// A bounded numeric text field with the app's standard spinner controls.
|
||||
@@ -91,6 +92,7 @@ class _AppNumberInputState extends State<AppNumberInput> {
|
||||
: TextInputType.number,
|
||||
inputFormatters: formatters,
|
||||
enabled: widget.enabled,
|
||||
padding: AppDesignTokens.numberTextFieldPadding,
|
||||
features: [
|
||||
shad.InputFeature.spinner(
|
||||
step: widget.step,
|
||||
|
||||
@@ -48,11 +48,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "gopeed");
|
||||
gtk_header_bar_set_title(header_bar, "Gopeed");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "gopeed");
|
||||
gtk_window_set_title(window, "Gopeed");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -26,16 +26,18 @@ class AppDelegate: FlutterAppDelegate {
|
||||
}
|
||||
|
||||
override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
if !flag {
|
||||
for window in NSApp.windows {
|
||||
if !window.isVisible {
|
||||
window.setIsVisible(true)
|
||||
}
|
||||
window.makeKeyAndOrderFront(self)
|
||||
// Closed child windows can remain in NSApp.windows. Restoring every
|
||||
// window resurrects completed create-task forms with their last frame.
|
||||
// Use the main window even when an unfinished child is still visible.
|
||||
if let window = sender.windows.first(where: { $0 is MainFlutterWindow }) {
|
||||
if window.isMiniaturized {
|
||||
window.deminiaturize(self)
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(self)
|
||||
sender.activate(ignoringOtherApps: true)
|
||||
}
|
||||
return true
|
||||
// We handled reopening; AppKit must not restore other retained windows.
|
||||
return false
|
||||
}
|
||||
|
||||
override func application(_ sender: NSApplication, openFile filename: String) -> Bool {
|
||||
|
||||
@@ -330,7 +330,7 @@
|
||||
</items>
|
||||
<point key="canvasLocation" x="142" y="-258"/>
|
||||
</menu>
|
||||
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
||||
<window title="Gopeed" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/app/application/app_notification_controller.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/core/common/api_server_state.dart';
|
||||
import 'package:gopeed/core/common/start_config.dart';
|
||||
import 'package:gopeed/core/common/task_event.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||
final calls = <MethodCall>[];
|
||||
var permissionGranted = true;
|
||||
late StreamController<TaskEvent> events;
|
||||
|
||||
setUp(() {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
MacOSFlutterLocalNotificationsPlugin.registerWith();
|
||||
calls.clear();
|
||||
permissionGranted = true;
|
||||
events = StreamController<TaskEvent>.broadcast();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return permissionGranted;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
unawaited(events.close());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
Future<ProviderContainer> start({required bool enabled}) async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)),
|
||||
appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(appRuntimeControllerProvider.future);
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
return container;
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map;
|
||||
|
||||
test('macOS requests alerts and sound when download notifications are enabled at startup', () async {
|
||||
await start(enabled: true);
|
||||
|
||||
expect(initialization()['requestAlertPermission'], isTrue);
|
||||
expect(initialization()['requestSoundPermission'], isTrue);
|
||||
expect(initialization()['requestBadgePermission'], isFalse);
|
||||
});
|
||||
|
||||
test('disabled notifications defer authorization until the setting is enabled', () async {
|
||||
final container = await start(enabled: false);
|
||||
expect(initialization()['requestAlertPermission'], isFalse);
|
||||
expect(initialization()['requestSoundPermission'], isFalse);
|
||||
|
||||
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||
runtime.setNotificationsEnabled(true);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
|
||||
expect(initialization()['requestAlertPermission'], isTrue);
|
||||
expect(initialization()['requestSoundPermission'], isTrue);
|
||||
|
||||
runtime.setNotificationsEnabled(false);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
expect(initialization()['requestAlertPermission'], isFalse);
|
||||
expect(initialization()['requestSoundPermission'], isFalse);
|
||||
});
|
||||
|
||||
test('denied macOS authorization does not fail notification controller startup', () async {
|
||||
permissionGranted = false;
|
||||
final container = await start(enabled: true);
|
||||
|
||||
expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue);
|
||||
});
|
||||
|
||||
test('terminal task events notify once after enabling and stop after disabling', () async {
|
||||
final container = await start(enabled: false);
|
||||
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||
const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip');
|
||||
events.add(done);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls.where((call) => call.method == 'show'), isEmpty);
|
||||
|
||||
runtime.setNotificationsEnabled(true);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
events.add(done);
|
||||
events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final notifications = calls.where((call) => call.method == 'show').toList();
|
||||
expect(notifications, hasLength(2));
|
||||
expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']);
|
||||
|
||||
runtime.setNotificationsEnabled(false);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
events.add(done);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls.where((call) => call.method == 'show'), hasLength(2));
|
||||
});
|
||||
}
|
||||
|
||||
class _TestNotificationController extends AppNotificationController {
|
||||
_TestNotificationController(this.taskEvents);
|
||||
|
||||
@override
|
||||
final Stream<TaskEvent> taskEvents;
|
||||
}
|
||||
|
||||
class _TestRuntimeController extends AppRuntimeController {
|
||||
_TestRuntimeController(this.enabled);
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Future<AppRuntimeState> build() async => _runtime(enabled);
|
||||
|
||||
void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled));
|
||||
|
||||
AppRuntimeState _runtime(bool enabled) => AppRuntimeState(
|
||||
startConfig: StartConfig(),
|
||||
apiServerState: ApiServerState.fromJson({}),
|
||||
downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled,
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,9 @@ void main() {
|
||||
test('create-task child window has a localized native title', () {
|
||||
expect(
|
||||
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
||||
'Create Task - Gopeed',
|
||||
'Gopeed-Create Task',
|
||||
);
|
||||
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed');
|
||||
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务');
|
||||
});
|
||||
|
||||
test('create-task payload only carries initial task data', () {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/widgets.dart' as flutter show Row;
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
@@ -9,7 +11,10 @@ import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/core/capabilities/storage_capability.dart';
|
||||
@@ -19,10 +24,63 @@ import 'package:gopeed/shared/services/download_directory_picker.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_design_tokens.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final navigationFails in [false, true]) {
|
||||
testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final events = <String>[];
|
||||
const channel = MethodChannel('window_manager');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
events.add(call.method);
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.createTask, (_) {
|
||||
events.add('created');
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||
events.add('downloads');
|
||||
if (navigationFails) throw StateError('Navigation unavailable');
|
||||
return const RpcUnit();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: CreateTaskWindowPage(
|
||||
windowController: WindowController.fromWindowId('child'),
|
||||
initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Confirm'));
|
||||
// The mocked native close deliberately leaves the widget mounted.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(events, ['created', 'downloads', 'close']);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
@@ -240,7 +298,10 @@ void main() {
|
||||
|
||||
final connectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
|
||||
expect(connectionsInput, findsOneWidget);
|
||||
expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth);
|
||||
expect(
|
||||
tester.getSize(connectionsInput).width,
|
||||
tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width,
|
||||
);
|
||||
final connectionsField = tester.widget<shad.TextField>(connectionsInput);
|
||||
expect(connectionsField.controller!.text, '12');
|
||||
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
|
||||
@@ -399,6 +460,57 @@ void main() {
|
||||
expect(submitted?.opts?.asDefaultPath, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(700, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
id: 'resolved-id',
|
||||
res: Resource(
|
||||
name: 'server-generated-name',
|
||||
files: [FileInfo(name: 'video.mp4', size: 1024)],
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(_fieldText(tester, 'create-task-rename-input'), isEmpty);
|
||||
final createButton = tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button')));
|
||||
expect(createButton.child, isA<Text>());
|
||||
final createText = createButton.child as Text;
|
||||
expect(createText.data, 'Create');
|
||||
expect(createText.maxLines, 1);
|
||||
expect(createText.softWrap, isFalse);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))),
|
||||
tester.getSize(find.byKey(const ValueKey('resolve-create-button'))),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_task_actions.dart';
|
||||
import 'package:gopeed/l10n/l10n.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 1024.0]) {
|
||||
for (final language in ['en', 'zh', 'de']) {
|
||||
testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 760);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
var submitting = false;
|
||||
var cancelled = false;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => Localizations.override(
|
||||
context: context,
|
||||
locale: Locale(language),
|
||||
delegates: const [AppLocalizations.delegate],
|
||||
child: AppComponentThemes(
|
||||
child: Center(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => shad.AlertDialog(
|
||||
padding: const EdgeInsets.all(18),
|
||||
content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100),
|
||||
actions: [
|
||||
Flexible(
|
||||
child: ResolveTaskActions(
|
||||
submitting: submitting,
|
||||
onCancel: () => cancelled = true,
|
||||
onCreate: () => setState(() => submitting = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final cancel = find.byKey(const ValueKey('resolve-cancel-button'));
|
||||
final create = find.byKey(const ValueKey('resolve-create-button'));
|
||||
final cancelRect = tester.getRect(cancel);
|
||||
final createRect = tester.getRect(create);
|
||||
expect(cancelRect.size, createRect.size);
|
||||
expect(cancelRect.center.dy, createRect.center.dy);
|
||||
expect(cancelRect.left, greaterThanOrEqualTo(0));
|
||||
expect(createRect.right, lessThanOrEqualTo(width));
|
||||
expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget);
|
||||
await tester.tap(cancel);
|
||||
expect(cancelled, isTrue);
|
||||
await tester.tap(create);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(tester.widget<AppLoadingButton>(create).loading, isTrue);
|
||||
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
|
||||
expect(tester.getRect(cancel), cancelRect);
|
||||
expect(tester.getRect(create), createRect);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('provides the standard text field appearance', (tester) async {
|
||||
late shad.TextFieldTheme textFieldTheme;
|
||||
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
textFieldTheme = shad.ComponentTheme.of<shad.TextFieldTheme>(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(textFieldTheme.filled, isTrue);
|
||||
expect(textFieldTheme.border, Border.all(color: AppPalette.light.border));
|
||||
expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius));
|
||||
expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding);
|
||||
});
|
||||
|
||||
testWidgets('provides the standard checkbox metrics', (tester) async {
|
||||
late shad.CheckboxTheme checkboxTheme;
|
||||
|
||||
|
||||
@@ -134,7 +134,10 @@ void main() {
|
||||
of: find.byKey(const ValueKey('text-only-loading-button')),
|
||||
matching: find.byType(shad.CircularProgressIndicator),
|
||||
);
|
||||
expect(find.text('Login'), findsNothing);
|
||||
final hiddenLabel = find.text('Login');
|
||||
expect(hiddenLabel, findsOneWidget);
|
||||
expect(tester.widget<Opacity>(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0);
|
||||
expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget);
|
||||
expect(loader, findsOneWidget);
|
||||
expect(tester.getRect(loader).center, bounds.center);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:gopeed/app/app.dart';
|
||||
import 'package:gopeed/app/router/app_router.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
|
||||
import 'package:gopeed/app/application/app_appearance_controller.dart';
|
||||
import 'package:gopeed/app/application/app_deep_link_controller.dart';
|
||||
import 'package:gopeed/app/application/app_notification_controller.dart';
|
||||
@@ -93,6 +96,38 @@ import 'package:gopeed/util/updater.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en');
|
||||
|
||||
testWidgets('child success returns to downloading from completed and another page', (tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 768));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
|
||||
tasksControllerProvider.overrideWith(CompletedTasksController.new),
|
||||
settingsControllerProvider.overrideWith(FakeSettingsController.new),
|
||||
],
|
||||
child: const GopeedApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Completed'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('done.zip'), findsOneWidget);
|
||||
|
||||
// Exercise the same serialized operation dispatched by a child window.
|
||||
await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {});
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('done.zip'), findsNothing);
|
||||
expect(find.text('Create Task'), findsOneWidget);
|
||||
|
||||
GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings');
|
||||
await tester.pumpAndSettle();
|
||||
await LocalAppCapabilities.instance.capabilities.navigation.showDownloadingTasks();
|
||||
await tester.pumpAndSettle();
|
||||
expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/');
|
||||
expect(find.text('done.zip'), findsNothing);
|
||||
expect(find.text('Create Task'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('Web MCP endpoint excludes page path, query, and hash route', () {
|
||||
expect(
|
||||
webMcpEndpoint(Uri.parse('https://gopeed.example:8443/app?source=desktop#/settings/advanced')),
|
||||
@@ -414,7 +449,7 @@ void main() {
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const ExtensionsPage(),
|
||||
home: const AppComponentThemes(child: ExtensionsPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -434,6 +469,7 @@ void main() {
|
||||
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0')));
|
||||
final rightmostCardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-2')));
|
||||
final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark')));
|
||||
expect(searchRect.height, closeTo(sortRect.height, 0.01));
|
||||
expect(searchRect.width, 240);
|
||||
expect(searchRect.width, lessThan(cardRect.width));
|
||||
expect(sortRect.left - searchRect.right, closeTo(10, 0.01));
|
||||
@@ -466,6 +502,7 @@ void main() {
|
||||
expect(find.byType(PrimaryRail), findsOneWidget);
|
||||
final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container')));
|
||||
expect(compactSearchRect.width, lessThan(240));
|
||||
expect(compactSearchRect.height, closeTo(sortRect.height, 0.01));
|
||||
expect(
|
||||
tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).center.dy,
|
||||
closeTo(compactSearchRect.center.dy, 0.01),
|
||||
@@ -710,7 +747,7 @@ void main() {
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const ExtensionsPage(),
|
||||
home: const AppComponentThemes(child: ExtensionsPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1860,6 +1897,40 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('loading button preserves its content dimensions', (WidgetTester tester) async {
|
||||
var loading = false;
|
||||
late StateSetter setButtonState;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Center(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, update) {
|
||||
setButtonState = update;
|
||||
return AppLoadingButton(
|
||||
key: const ValueKey('dimension-stable-loading-button'),
|
||||
onPressed: () {},
|
||||
loading: loading,
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
child: const Text('Confirm'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final button = find.byKey(const ValueKey('dimension-stable-loading-button'));
|
||||
final idleSize = tester.getSize(button);
|
||||
|
||||
setButtonState(() => loading = true);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getSize(button), idleSize);
|
||||
expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
final runtimeController = FailingTrackerRuntimeController();
|
||||
@@ -2302,7 +2373,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input'));
|
||||
final createRenameInput = find.byKey(const ValueKey('create-task-rename-input'));
|
||||
final createConnectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
|
||||
expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height);
|
||||
expect(tester.getSize(createConnectionsInput), tester.getSize(createRenameInput));
|
||||
final createDirectoryField = tester.widget<shad.TextField>(createDirectoryInput);
|
||||
final createRenameField = tester.widget<shad.TextField>(
|
||||
find.descendant(of: createRenameInput, matching: find.byType(AppTextField)),
|
||||
@@ -2404,6 +2477,27 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('create task rename stays user-controlled and resets when the URL changes', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(700, 500));
|
||||
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
|
||||
await tester.pump();
|
||||
|
||||
final urlInput = find.byKey(const ValueKey('create-task-url-input'));
|
||||
final renameInput = find.byKey(const ValueKey('create-task-rename-input'));
|
||||
await tester.enterText(urlInput, 'https://example.com/first.zip');
|
||||
await tester.enterText(renameInput, 'custom-name.zip');
|
||||
expect(find.text('custom-name.zip'), findsOneWidget);
|
||||
|
||||
await tester.enterText(urlInput, 'https://example.com/second.zip');
|
||||
await tester.pump();
|
||||
|
||||
final renameField = tester.widget<AppTextField>(
|
||||
find.descendant(of: renameInput, matching: find.byType(AppTextField)),
|
||||
);
|
||||
expect(renameField.controller!.text, isEmpty);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
});
|
||||
|
||||
testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(700, 500));
|
||||
await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness()));
|
||||
@@ -2591,6 +2685,7 @@ void main() {
|
||||
expect(treeFinder, findsOneWidget);
|
||||
final tree = tester.widget<VirtualTreeView<dynamic>>(treeFinder);
|
||||
expect(tree.branchLine, same(shad.BranchLine.path));
|
||||
expect(tree.rowHeight, 36);
|
||||
expect(
|
||||
tester.widgetList<shad.Checkbox>(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null),
|
||||
isTrue,
|
||||
@@ -4819,7 +4914,7 @@ class _CreateTaskPageHarness extends StatelessWidget {
|
||||
return shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const CreateTaskWindowPage(),
|
||||
home: const AppComponentThemes(child: CreateTaskWindowPage()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gopeed",
|
||||
"short_name": "gopeed",
|
||||
"name": "Gopeed",
|
||||
"short_name": "Gopeed",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
|
||||
@@ -56,7 +56,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
flutter::DartProject project(L"data");
|
||||
std::vector<std::string> command_line_arguments = GetCommandLineArguments();
|
||||
if (!IsMultiWindowLaunch(command_line_arguments) &&
|
||||
SendAppLinkToInstance(L"gopeed")) {
|
||||
SendAppLinkToInstance(L"Gopeed")) {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"gopeed", origin, size)) {
|
||||
if (!window.Create(L"Gopeed", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
Reference in new issue
Block a user