Skip to content
Draft
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,14 @@ If you would like to contribute, please fork the repository and submit a pull re

## Support

This project is completely free and open source. Your support helps maintain it.
Icarus is completely free and open source. If it helps your team, you can [buy Dara a coffee](https://www.buymeacoffee.com/daradoescode) to support its continued development.

## Sponsors

<a href="https://www.greptile.com/?utm_source=oss_badge&utm_medium=readme&utm_campaign=greptile_for_open_source">
<img src="https://www.greptile.com/badge.svg" alt="Greptile: The War on Bugs" width="600">
</a>

[Greptile's Open Source Program](https://www.greptile.com/open-source) provides AI code review for the project.

**[OpenAI — Codex for Open Source](https://openai.com/form/codex-for-oss/)** provides tooling and credits for open-source maintenance.
4 changes: 4 additions & 0 deletions lib/const/hive_boxes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ class HiveBoxNames {
static const appPreferencesBox = "app_preferences_box";
static const favoriteAgentsBox = "favorite_agents_box";
static const pinnedItemsBox = "pinned_items_box";
static const appFlagsBox = "app_flags_box";

static const appLaunchCountKey = "app_launch_count";
static const supportPromptShownKey = "support_prompt_shown";
}
9 changes: 8 additions & 1 deletion lib/const/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ class Settings {
}

static final Uri dicordLink = Uri.parse("https://discord.gg/PN2uKwCqYB");
static final Uri buyMeACoffeeLink =
Uri.parse("https://www.buymeacoffee.com/daradoescode");
static final Uri greptileOpenSourceLink =
Uri.parse("https://www.greptile.com/open-source");
static final Uri openAICodexForOssLink =
Uri.parse("https://openai.com/form/codex-for-oss/");

static const Duration autoSaveOffset = Duration(seconds: 15);
static const int versionNumber = 95;
Expand Down Expand Up @@ -227,9 +233,10 @@ class Settings {
required Color backgroundColor,
String? actionLabel,
VoidCallback? onActionPressed,
Duration autoCloseDuration = const Duration(seconds: 3),
}) {
toastification.showCustom(
autoCloseDuration: const Duration(seconds: 3),
autoCloseDuration: autoCloseDuration,
alignment: Alignment.bottomCenter,
builder: (context, holder) {
final actionIsVisible = actionLabel != null &&
Expand Down
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ Future<void> main(List<String> args) async {
await Hive.openBox<AppPreferences>(HiveBoxNames.appPreferencesBox);
await Hive.openBox<bool>(HiveBoxNames.favoriteAgentsBox);
await Hive.openBox<int>(HiveBoxNames.pinnedItemsBox);
final appFlagsBox = await Hive.openBox<int>(HiveBoxNames.appFlagsBox);
final launchCount =
appFlagsBox.get(HiveBoxNames.appLaunchCountKey, defaultValue: 0) ?? 0;
await appFlagsBox.put(HiveBoxNames.appLaunchCountKey, launchCount + 1);
await Hive.openBox<dynamic>(AnalyticsService.storageBoxName);

await MapThemeProfilesProvider.bootstrap();
Expand Down
36 changes: 36 additions & 0 deletions lib/widgets/folder_navigator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import 'package:desktop_updater/desktop_updater.dart';
import 'package:flutter/foundation.dart' show debugPrint, kDebugMode, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce/hive.dart';
import 'package:icarus/const/coordinate_system.dart';
import 'package:icarus/const/hive_boxes.dart';
import 'package:icarus/const/routes.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/const/update_checker.dart';
Expand All @@ -25,6 +27,7 @@ import 'package:icarus/widgets/folder_content.dart';
import 'package:icarus/widgets/folder_edit_dialog.dart';
import 'package:icarus/widgets/ica_drop_target.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl;

class FolderNavigator extends ConsumerStatefulWidget {
const FolderNavigator({super.key});
Expand Down Expand Up @@ -85,10 +88,43 @@ class _FolderNavigatorState extends ConsumerState<FolderNavigator> {
_warnWebView();

_warnDemo();

_maybeShowSupportPrompt();
}
});
}

Future<void> _maybeShowSupportPrompt() async {
if (kIsWeb) return;
if (!Hive.isBoxOpen(HiveBoxNames.appFlagsBox)) return;

final flags = Hive.box<int>(HiveBoxNames.appFlagsBox);
final launchCount =
flags.get(HiveBoxNames.appLaunchCountKey, defaultValue: 0) ?? 0;
final hasShownPrompt =
flags.get(HiveBoxNames.supportPromptShownKey, defaultValue: 0) == 1;
if (launchCount < 3 || hasShownPrompt) return;

// Claim the prompt before the delay so a route change or quick exit never
// turns a one-time invitation into a recurring interruption.
await flags.put(HiveBoxNames.supportPromptShownKey, 1);
if (Platform.isWindows && !isWebViewInitialized) return;
Comment on lines +108 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Move the persisted “shown” flag after the readiness check.

Line 110 writes supportPromptShownKey = 1. Line 111 can then return before Settings.showToast runs. On a Windows launch with an uninitialized WebView, the prompt is not shown, but later launches return at Line 106 because the flag is already set.

Move the readiness check before the write. Keep the write before the six-second delay.

Proposed fix
-    // Claim the prompt before the delay so a route change or quick exit never
-    // turns a one-time invitation into a recurring interruption.
-    await flags.put(HiveBoxNames.supportPromptShownKey, 1);
     if (Platform.isWindows && !isWebViewInitialized) return;
+    // Claim the prompt before the delay so a route change or quick exit never
+    // turns a one-time invitation into a recurring interruption.
+    await flags.put(HiveBoxNames.supportPromptShownKey, 1);

As per coding guidelines, “If a library write path is uncertain, fail loudly without saving rather than persisting potentially incorrect data.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Claim the prompt before the delay so a route change or quick exit never
// turns a one-time invitation into a recurring interruption.
await flags.put(HiveBoxNames.supportPromptShownKey, 1);
if (Platform.isWindows && !isWebViewInitialized) return;
if (Platform.isWindows && !isWebViewInitialized) return;
// Claim the prompt before the delay so a route change or quick exit never
// turns a one-time invitation into a recurring interruption.
await flags.put(HiveBoxNames.supportPromptShownKey, 1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/widgets/folder_navigator.dart` around lines 108 - 111, In the prompt flow
around the readiness check and Settings.showToast, move the Platform.isWindows
&& !isWebViewInitialized early return before writing
flags.put(HiveBoxNames.supportPromptShownKey, 1). Keep the persisted write
immediately before the existing six-second delay so the flag is saved only when
the prompt can proceed.

Source: Coding guidelines


await Future<void>.delayed(const Duration(seconds: 6));
if (!mounted) return;

Settings.showToast(
message:
'Icarus is free and open source. If it helps your team, you can help keep it going.',
backgroundColor: Settings.tacticalVioletTheme.card,
actionLabel: 'Buy me a coffee',
onActionPressed: () {
launchUrl(Settings.buyMeACoffeeLink);
},
autoCloseDuration: const Duration(seconds: 8),
);
}

void _warnWebView() async {
if (kIsWeb) return;
if (!Platform.isWindows) return;
Expand Down
117 changes: 116 additions & 1 deletion lib/widgets/settings_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:icarus/widgets/map_theme_settings_section.dart';
import 'package:icarus/widgets/settings_scope_card.dart';
import 'package:icarus/widgets/text_editing_shortcut_scope.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl;

enum _SettingsMode {
strategy,
Expand All @@ -31,6 +32,7 @@ enum _SettingsSection {
globalMapVisibility,
globalMapProfiles,
globalPrivacy,
globalSupport,
shortcuts,
}

Expand Down Expand Up @@ -72,7 +74,10 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
};
final scopeValue = switch (_mode) {
_SettingsMode.strategy => activeStrategyName,
_SettingsMode.global => 'Defaults',
_SettingsMode.global =>
_selectedSection == _SettingsSection.globalSupport
? 'About & sponsors'
: 'Defaults',
_SettingsMode.shortcuts => 'Keybinds',
};

Expand Down Expand Up @@ -193,6 +198,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> {
case _SettingsSection.globalMapVisibility:
case _SettingsSection.globalMapProfiles:
case _SettingsSection.globalPrivacy:
case _SettingsSection.globalSupport:
return _SettingsMode.global;
case _SettingsSection.shortcuts:
return _SettingsMode.shortcuts;
Expand Down Expand Up @@ -562,6 +568,51 @@ class _GlobalSettingsSections extends ConsumerWidget {
scope: MapThemeSettingsScope.global,
),
),
const SizedBox(height: 20),
const _SectionDivider(),
const SizedBox(height: 20),
SettingsScopeCard(
key: sectionKeys[_SettingsSection.globalSupport],
title: "Sponsors & support",
description:
"Icarus stays free through open-source programs and support from its users.",
child: Column(
children: [
_SupportLinkTile(
icon: Icons.bug_report_outlined,
title: "Greptile",
description:
"AI code review through Greptile's Open Source Program.",
actionLabel: "View program",
onPressed: () {
launchUrl(Settings.greptileOpenSourceLink);
},
),
const _SettingsItemDivider(),
_SupportLinkTile(
icon: Icons.code_outlined,
title: "OpenAI",
description:
"Tooling and credits through Codex for Open Source.",
actionLabel: "View program",
onPressed: () {
launchUrl(Settings.openAICodexForOssLink);
},
),
const _SettingsItemDivider(),
_SupportLinkTile(
icon: Icons.local_cafe_outlined,
title: "Support Icarus",
description:
"If Icarus helps your team, you can help keep development going.",
actionLabel: "Buy me a coffee",
onPressed: () {
launchUrl(Settings.buyMeACoffeeLink);
Comment on lines +581 to +610

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect every launchUrl call to use result-aware handling.
if rg -nP '^\s*launchUrl\s*\(' lib/widgets/settings_tab.dart; then
  echo "Found a fire-and-forget launchUrl call"
  exit 1
fi

rg -n -C 5 'launchUrl\(' lib/widgets/settings_tab.dart

Repository: SunkenInTime/icarus

Length of output: 387


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant settings_tab.dart section:"
sed -n '560,625p' lib/widgets/settings_tab.dart

echo
echo "Import/dependency declarations:"
rg -n "url_launcher|launchUrl|Toast|showDialog|ErrorMessage|error" pubspec.yaml pubspec.lock

Repository: SunkenInTime/icarus

Length of output: 3225


🌐 Web query:

url_launcher package 6.3.1 launchUrl returns Future<bool> behavior if platform handler not found

💡 Result:

In the url_launcher package version 6.3.1, the launchUrl method returns a Future<bool> that indicates whether the URL was launched successfully [1][2]. When a platform handler for the requested URL cannot be found, the standard behavior is for launchUrl to return false [1][2]. Depending on the specific platform and the nature of the failure, it may alternatively throw a PlatformException [1][2]. It is important to note the following regarding this behavior: 1. Handling Failures: Because launchUrl can return false when an operation fails to launch, the package documentation explicitly recommends checking the boolean return value to handle such scenarios [3][1]. For example: if (!await launchUrl(url)) { // Handle failure (e.g., show an error message or try a fallback) } 2. Limitations of canLaunchUrl: You should not rely solely on canLaunchUrl to predict the outcome of launchUrl [3][4]. On many platforms (especially mobile and web), canLaunchUrl may return false even if launchUrl would succeed, often due to platform-specific security configurations like LSApplicationQueriesSchemes on iOS or <queries> elements in the Android manifest [5][6][2]. 3. Web Considerations: On web platforms, launchUrl behavior can sometimes lead to false negatives (returning false despite the operation potentially succeeding in the browser) depending on how the window or tab is opened (e.g., usage of webOnlyWindowName) [7]. For mission-critical operations, it is best to attempt the launchUrl call directly and implement fallback logic within the else or if (!result) block rather than disabling functionality based on a canLaunchUrl check [3][6].

Citations:


Handle external-link failures before shipping.

launchUrl(...) returns a Future<bool> at all three support-link call sites, and these callbacks discard the result. If the platform cannot launch the URL, the action fails silently. Route these callbacks through one async helper that checks false results and catches platform errors, then reports the failure with the existing toast/error UI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/widgets/settings_tab.dart` around lines 581 - 610, Replace the direct
launchUrl calls in the three _SupportLinkTile onPressed callbacks with a shared
async helper that accepts the target URL, checks for a false launch result,
catches platform exceptions, and reports failures through the existing
toast/error UI. Keep each tile’s URL unchanged and ensure the callbacks await or
return the helper’s Future.

Source: Coding guidelines

},
),
],
),
),
const SizedBox(height: 24),
],
);
Expand Down Expand Up @@ -1218,6 +1269,12 @@ class _SettingsNavigationRail extends StatelessWidget {
isSelected: selectedSection == _SettingsSection.shortcuts,
onTap: () => onSectionSelected(_SettingsSection.shortcuts),
),
_SettingsNavItem(
icon: Icons.favorite_border_outlined,
label: "Sponsors",
isSelected: selectedSection == _SettingsSection.globalSupport,
onTap: () => onSectionSelected(_SettingsSection.globalSupport),
),
],
),
);
Expand Down Expand Up @@ -1508,6 +1565,64 @@ class _SettingsToggleTile extends StatelessWidget {
}
}

class _SupportLinkTile extends StatelessWidget {
const _SupportLinkTile({
required this.icon,
required this.title,
required this.description,
required this.actionLabel,
required this.onPressed,
});

final IconData icon;
final String title;
final String description;
final String actionLabel;
final VoidCallback onPressed;

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_SettingLeadingIcon(
icon: icon,
accentColor: Settings.tacticalVioletTheme.mutedForeground,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
Text(
description,
style: ShadTheme.of(context).textTheme.small.copyWith(
color: Settings.tacticalVioletTheme.mutedForeground,
height: 1.3,
),
),
],
),
),
const SizedBox(width: 12),
ShadButton.secondary(
size: ShadButtonSize.sm,
onPressed: onPressed,
child: Text(actionLabel),
),
],
),
);
}
}

class _SettingLeadingIcon extends StatelessWidget {
const _SettingLeadingIcon({
required this.icon,
Expand Down
Loading