diff --git a/README.md b/README.md
index 0c4c40a1..6290d7fa 100644
--- a/README.md
+++ b/README.md
@@ -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
+
+
+
+
+
+[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.
diff --git a/lib/const/hive_boxes.dart b/lib/const/hive_boxes.dart
index 88594cc7..12c4c8b4 100644
--- a/lib/const/hive_boxes.dart
+++ b/lib/const/hive_boxes.dart
@@ -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";
}
diff --git a/lib/const/settings.dart b/lib/const/settings.dart
index 504f34be..fbfdf301 100644
--- a/lib/const/settings.dart
+++ b/lib/const/settings.dart
@@ -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;
@@ -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 &&
diff --git a/lib/main.dart b/lib/main.dart
index 1a88759b..d316b81b 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -89,6 +89,10 @@ Future main(List args) async {
await Hive.openBox(HiveBoxNames.appPreferencesBox);
await Hive.openBox(HiveBoxNames.favoriteAgentsBox);
await Hive.openBox(HiveBoxNames.pinnedItemsBox);
+ final appFlagsBox = await Hive.openBox(HiveBoxNames.appFlagsBox);
+ final launchCount =
+ appFlagsBox.get(HiveBoxNames.appLaunchCountKey, defaultValue: 0) ?? 0;
+ await appFlagsBox.put(HiveBoxNames.appLaunchCountKey, launchCount + 1);
await Hive.openBox(AnalyticsService.storageBoxName);
await MapThemeProfilesProvider.bootstrap();
diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart
index 5da78ada..cdba8596 100644
--- a/lib/widgets/folder_navigator.dart
+++ b/lib/widgets/folder_navigator.dart
@@ -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';
@@ -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});
@@ -85,10 +88,43 @@ class _FolderNavigatorState extends ConsumerState {
_warnWebView();
_warnDemo();
+
+ _maybeShowSupportPrompt();
}
});
}
+ Future _maybeShowSupportPrompt() async {
+ if (kIsWeb) return;
+ if (!Hive.isBoxOpen(HiveBoxNames.appFlagsBox)) return;
+
+ final flags = Hive.box(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;
+
+ await Future.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;
diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart
index 7ee83a88..9a8ac207 100644
--- a/lib/widgets/settings_tab.dart
+++ b/lib/widgets/settings_tab.dart
@@ -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,
@@ -31,6 +32,7 @@ enum _SettingsSection {
globalMapVisibility,
globalMapProfiles,
globalPrivacy,
+ globalSupport,
shortcuts,
}
@@ -72,7 +74,10 @@ class _SettingsTabState extends ConsumerState {
};
final scopeValue = switch (_mode) {
_SettingsMode.strategy => activeStrategyName,
- _SettingsMode.global => 'Defaults',
+ _SettingsMode.global =>
+ _selectedSection == _SettingsSection.globalSupport
+ ? 'About & sponsors'
+ : 'Defaults',
_SettingsMode.shortcuts => 'Keybinds',
};
@@ -193,6 +198,7 @@ class _SettingsTabState extends ConsumerState {
case _SettingsSection.globalMapVisibility:
case _SettingsSection.globalMapProfiles:
case _SettingsSection.globalPrivacy:
+ case _SettingsSection.globalSupport:
return _SettingsMode.global;
case _SettingsSection.shortcuts:
return _SettingsMode.shortcuts;
@@ -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);
+ },
+ ),
+ ],
+ ),
+ ),
const SizedBox(height: 24),
],
);
@@ -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),
+ ),
],
),
);
@@ -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,