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: The War on Bugs + + +[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,