From c50258ca5eab0774c9e70ddb4e721dabcd3d4a81 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 23 Jul 2026 14:50:02 -0700 Subject: [PATCH] Add Discord Rich Presence --- lib/hive/hive_adapters.g.dart | 7 +- lib/hive/hive_adapters.g.yaml | 4 +- lib/main.dart | 72 ++++++ lib/providers/user_preferences_provider.dart | 11 + lib/services/discord_presence_service.dart | 227 +++++++++++++++++++ lib/widgets/settings_tab.dart | 13 ++ pubspec.lock | 16 ++ pubspec.yaml | 1 + test/discord_presence_service_test.dart | 71 ++++++ 9 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 lib/services/discord_presence_service.dart create mode 100644 test/discord_presence_service_test.dart diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 577ea68c..4afe5a10 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1324,13 +1324,14 @@ class AppPreferencesAdapter extends TypeAdapter { drawingThickness: fields[15] == null ? Settings.defaultStrokeThickness : (fields[15] as num).toDouble(), + discordPresenceEnabled: fields[17] == null ? true : fields[17] as bool, ); } @override void write(BinaryWriter writer, AppPreferences obj) { writer - ..writeByte(16) + ..writeByte(17) ..writeByte(0) ..write(obj.defaultThemeProfileIdForNewStrategies) ..writeByte(1) @@ -1362,7 +1363,9 @@ class AppPreferencesAdapter extends TypeAdapter { ..writeByte(14) ..write(obj.drawingColorValue) ..writeByte(15) - ..write(obj.drawingThickness); + ..write(obj.drawingThickness) + ..writeByte(17) + ..write(obj.discordPresenceEnabled); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index ec24c0d4..79e08a30 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -443,7 +443,7 @@ types: index: 4 AppPreferences: typeId: 28 - nextIndex: 17 + nextIndex: 18 fields: defaultThemeProfileIdForNewStrategies: index: 0 @@ -477,6 +477,8 @@ types: index: 14 drawingThickness: index: 15 + discordPresenceEnabled: + index: 17 PlacedViewConeAgent: typeId: 29 nextIndex: 10 diff --git a/lib/main.dart b/lib/main.dart index a9169cbf..404c17b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,11 +19,16 @@ import 'package:icarus/const/routes.dart'; import 'package:icarus/const/second_instance_args.dart'; import 'package:icarus/const/settings.dart' show Settings; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/discord_presence_service.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/global_shortcuts.dart'; @@ -217,6 +222,13 @@ class MyApp extends ConsumerStatefulWidget { class _MyAppState extends ConsumerState { StreamSubscription>? _secondInstanceSub; + late final DiscordPresenceService _discordPresence; + ProviderSubscription? _discordStrategySub; + ProviderSubscription? _discordMapSub; + ProviderSubscription? _discordPreferencesSub; + ProviderSubscription>? _discordAgentSub; + ProviderSubscription>? _discordAbilitySub; + Timer? _discordSyncDebounce; Future _loadFromFilePathWithWarning(String filePath) async { try { @@ -235,6 +247,30 @@ class _MyAppState extends ConsumerState { void initState() { super.initState(); + _discordPresence = DiscordPresenceService(); + _discordStrategySub = ref.listenManual( + strategyProvider, + (_, __) => _scheduleDiscordSync(), + ); + _discordMapSub = ref.listenManual( + mapProvider, + (_, __) => _scheduleDiscordSync(), + ); + _discordAgentSub = ref.listenManual( + agentProvider, + (_, __) => _scheduleDiscordSync(), + ); + _discordAbilitySub = ref.listenManual( + abilityProvider, + (_, __) => _scheduleDiscordSync(), + ); + _discordPreferencesSub = ref.listenManual( + appPreferencesProvider, + (_, __) => _syncDiscordPresence(), + ); + + unawaited(_syncDiscordPresence()); + WidgetsBinding.instance.addPostFrameCallback((_) { if (widget.data.isEmpty) return; @@ -263,9 +299,45 @@ class _MyAppState extends ConsumerState { @override void dispose() { _secondInstanceSub?.cancel(); + _discordSyncDebounce?.cancel(); + _discordStrategySub?.close(); + _discordMapSub?.close(); + _discordAgentSub?.close(); + _discordAbilitySub?.close(); + _discordPreferencesSub?.close(); + unawaited(_discordPresence.dispose()); super.dispose(); } + /// Coalesces rapid state changes (e.g. placing several agents in a row) + /// into one presence update, keeping well under Discord's rate limit. + void _scheduleDiscordSync() { + _discordSyncDebounce?.cancel(); + _discordSyncDebounce = Timer( + const Duration(seconds: 2), + () => unawaited(_syncDiscordPresence()), + ); + } + + Future _syncDiscordPresence() async { + if (!mounted) return; + + final preferences = ref.read(appPreferencesProvider); + if (!preferences.discordPresenceEnabled) { + await _discordPresence.clear(); + return; + } + + await _discordPresence.update( + DiscordPresenceData.fromAppState( + strategy: ref.read(strategyProvider), + map: ref.read(mapProvider), + agentCount: ref.read(agentProvider).length, + abilityCount: ref.read(abilityProvider).length, + ), + ); + } + @override Widget build(BuildContext context) { return ToastificationWrapper( diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index 38a2e54f..b9368f3e 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -126,6 +126,7 @@ class AppPreferences extends HiveObject { final String librarySortOrderName; final int drawingColorValue; final double drawingThickness; + final bool discordPresenceEnabled; AppPreferences({ required this.defaultThemeProfileIdForNewStrategies, @@ -144,6 +145,7 @@ class AppPreferences extends HiveObject { this.librarySortOrderName = 'ascending', this.drawingColorValue = 0xFFFFFFFF, this.drawingThickness = Settings.defaultStrokeThickness, + this.discordPresenceEnabled = true, }) : customColorValues = List.unmodifiable(customColorValues ?? const []), customShortcutBindings = Map.unmodifiable(customShortcutBindings ?? const {}); @@ -165,6 +167,7 @@ class AppPreferences extends HiveObject { String? librarySortOrderName, int? drawingColorValue, double? drawingThickness, + bool? discordPresenceEnabled, }) { return AppPreferences( defaultThemeProfileIdForNewStrategies: @@ -191,6 +194,8 @@ class AppPreferences extends HiveObject { librarySortOrderName: librarySortOrderName ?? this.librarySortOrderName, drawingColorValue: drawingColorValue ?? this.drawingColorValue, drawingThickness: drawingThickness ?? this.drawingThickness, + discordPresenceEnabled: + discordPresenceEnabled ?? this.discordPresenceEnabled, ); } } @@ -553,6 +558,12 @@ class AppPreferencesNotifier extends Notifier { ); } + Future setDiscordPresenceEnabled(bool enabled) { + return _updatePreferences( + (current) => current.copyWith(discordPresenceEnabled: enabled), + ); + } + Future setShowSpawnBarrier(bool visible) { return _updatePreferences( (current) => current.copyWith(showSpawnBarrier: visible), diff --git a/lib/services/discord_presence_service.dart b/lib/services/discord_presence_service.dart new file mode 100644 index 00000000..6cd60761 --- /dev/null +++ b/lib/services/discord_presence_service.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:discord_rich_presence/discord_rich_presence.dart'; +import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; + +/// The privacy-safe information Icarus publishes to Discord. +/// +/// Strategy and page names are intentionally excluded because Discord Rich +/// Presence is public profile data. +class DiscordPresenceData { + const DiscordPresenceData({ + required this.details, + required this.state, + this.largeImageKey, + this.largeImageText, + this.smallImageKey, + this.smallImageText, + }); + + factory DiscordPresenceData.fromAppState({ + required StrategyState strategy, + required MapState map, + int agentCount = 0, + int abilityCount = 0, + }) { + if (strategy.stratName == null) { + return const DiscordPresenceData( + details: 'Browsing the strategy library', + state: 'Valorant strategy planner', + largeImageKey: 'icarus_logo', + largeImageText: 'Icarus', + ); + } + + final rawMapName = Maps.mapNames[map.currentMap] ?? map.currentMap.name; + final mapName = rawMapName.isEmpty + ? rawMapName + : '${rawMapName[0].toUpperCase()}${rawMapName.substring(1)}'; + + return DiscordPresenceData( + details: map.isAttack + ? 'Planning an attack on $mapName' + : 'Planning a defense on $mapName', + state: _boardSummary(agentCount: agentCount, abilityCount: abilityCount), + largeImageKey: '${map.currentMap.name}_thumbnail', + largeImageText: mapName, + smallImageKey: 'icarus_logo', + smallImageText: 'Icarus', + ); + } + + /// A live tally of what is on the board, e.g. "4 agents · 12 abilities". + static String _boardSummary({ + required int agentCount, + required int abilityCount, + }) { + final parts = [ + if (agentCount > 0) '$agentCount agent${agentCount == 1 ? '' : 's'}', + if (abilityCount > 0) + '$abilityCount abilit${abilityCount == 1 ? 'y' : 'ies'}', + ]; + if (parts.isEmpty) return 'Starting from an empty board'; + return '${parts.join(' · ')} on the board'; + } + + final String details; + final String state; + final String? largeImageKey; + final String? largeImageText; + final String? smallImageKey; + final String? smallImageText; + + @override + bool operator ==(Object other) => + other is DiscordPresenceData && + other.details == details && + other.state == state && + other.largeImageKey == largeImageKey && + other.largeImageText == largeImageText && + other.smallImageKey == smallImageKey && + other.smallImageText == smallImageText; + + @override + int get hashCode => Object.hash( + details, + state, + largeImageKey, + largeImageText, + smallImageKey, + smallImageText, + ); +} + +/// Owns the local Discord RPC connection for the lifetime of the application. +/// +/// Basic Rich Presence uses the public application ID only. It never uses a +/// Discord client secret or authenticates the user. +class DiscordPresenceService { + DiscordPresenceService({ + this.applicationId = defaultApplicationId, + Client Function(String applicationId)? clientFactory, + }) : _clientFactory = clientFactory ?? + ((applicationId) => Client(clientId: applicationId)); + + static const String defaultApplicationId = '1478874408528642101'; + + final String applicationId; + final Client Function(String applicationId) _clientFactory; + final DateTime _sessionStartedAt = DateTime.now(); + + Client? _client; + DiscordPresenceData? _lastPublished; + Future _operationQueue = Future.value(); + bool _connected = false; + bool _disposed = false; + + static bool get isSupported => !kIsWeb && Platform.isWindows; + + Future update(DiscordPresenceData presence) { + if (!isSupported || _disposed || presence == _lastPublished) { + return Future.value(); + } + + return _enqueue(() async { + if (_disposed || presence == _lastPublished) return; + + try { + await _ensureConnected(); + if (!_connected || _client == null) return; + + await _client!.setActivity( + Activity( + name: 'Icarus', + details: presence.details, + state: presence.state, + timestamps: ActivityTimestamps(start: _sessionStartedAt), + assets: + presence.largeImageKey == null && presence.smallImageKey == null + ? null + : ActivityAssets( + largeImage: presence.largeImageKey, + largeText: presence.largeImageText, + smallImage: presence.smallImageKey, + smallText: presence.smallImageText, + ), + ), + ); + _lastPublished = presence; + } catch (error, stackTrace) { + _connected = false; + _lastPublished = null; + await _disconnectClient(); + developer.log( + 'Discord Rich Presence is unavailable.', + name: 'DiscordPresenceService.update', + error: error, + stackTrace: stackTrace, + level: 700, + ); + } + }); + } + + Future clear() { + if (!isSupported || _disposed) return Future.value(); + + return _enqueue(() async { + _lastPublished = null; + await _disconnectClient(); + }); + } + + Future dispose() { + if (_disposed) return Future.value(); + _disposed = true; + + return _enqueue(() async { + _lastPublished = null; + await _disconnectClient(); + }); + } + + Future _ensureConnected() async { + if (_connected) return; + + final client = _clientFactory(applicationId); + await client.connect(); + _client = client; + _connected = true; + } + + Future _disconnectClient() async { + final client = _client; + _client = null; + _connected = false; + if (client == null) return; + + try { + await client.disconnect(); + } catch (error, stackTrace) { + developer.log( + 'Discord Rich Presence cleanup failed.', + name: 'DiscordPresenceService.disconnect', + error: error, + stackTrace: stackTrace, + level: 700, + ); + } + } + + Future _enqueue(Future Function() operation) { + final next = _operationQueue.then((_) => operation()); + _operationQueue = next.then( + (_) {}, + onError: (Object _, StackTrace __) {}, + ); + return next; + } + + @visibleForTesting + DiscordPresenceData? get lastPublished => _lastPublished; +} diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index cae706e7..7ee83a88 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -474,6 +474,19 @@ class _GlobalSettingsSections extends ConsumerWidget { .refreshAutosaveScheduling(); }, ), + const _SettingsItemDivider(), + _SettingsToggleTile( + icon: Icons.sports_esports_outlined, + title: "Discord Rich Presence", + description: + "Show the current map and attack or defense side on your Discord profile. Strategy names are never shared.", + value: appPreferences.discordPresenceEnabled, + onChanged: (value) { + ref + .read(appPreferencesProvider.notifier) + .setDiscordPresenceEnabled(value); + }, + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index dcb4fe8f..c58fb074 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" build: dependency: transitive description: @@ -297,6 +305,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + discord_rich_presence: + dependency: "direct main" + description: + name: discord_rich_presence + sha256: ed30f3aa310fb96afe1d410ac0263f4f3013943981935332db08a037f340f4fd + url: "https://pub.dev" + source: hosted + version: "1.1.1" equatable: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2afe3fb1..6e0a3dfb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: cryptography_plus: ^2.7.1 lucide_icons_flutter: 3.1.14+2 xml: ^6.5.0 + discord_rich_presence: ^1.1.1 dev_dependencies: diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart new file mode 100644 index 00000000..dfc9ad5b --- /dev/null +++ b/test/discord_presence_service_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/discord_presence_service.dart'; + +void main() { + group('DiscordPresenceData', () { + test('uses a generic library presence when no strategy is open', () { + final presence = DiscordPresenceData.fromAppState( + strategy: StrategyState( + isSaved: true, + stratName: null, + id: 'testID', + storageDirectory: null, + ), + map: MapState(currentMap: MapValue.ascent, isAttack: true), + ); + + expect(presence.details, 'Browsing the strategy library'); + expect(presence.state, 'Valorant strategy planner'); + expect(presence.largeImageKey, 'icarus_logo'); + }); + + test('shares map and side without sharing the strategy name', () { + final presence = DiscordPresenceData.fromAppState( + strategy: StrategyState( + isSaved: false, + stratName: 'Secret tournament execute', + id: 'strategy-id', + storageDirectory: null, + ), + map: MapState(currentMap: MapValue.lotus, isAttack: false), + ); + + expect(presence.details, 'Planning a defense on Lotus'); + expect(presence.state, 'Starting from an empty board'); + expect(presence.largeImageKey, 'lotus_thumbnail'); + expect(presence.largeImageText, 'Lotus'); + expect(presence.smallImageKey, 'icarus_logo'); + expect(presence.smallImageText, 'Icarus'); + expect(presence.details, isNot(contains('Secret'))); + expect(presence.state, isNot(contains('Secret'))); + }); + + test('summarizes what is on the board', () { + DiscordPresenceData build({int agents = 0, int abilities = 0}) => + DiscordPresenceData.fromAppState( + strategy: StrategyState( + isSaved: false, + stratName: 'A-site rush', + id: 'strategy-id', + storageDirectory: null, + ), + map: MapState(currentMap: MapValue.ascent, isAttack: true), + agentCount: agents, + abilityCount: abilities, + ); + + expect(build().details, 'Planning an attack on Ascent'); + expect(build().state, 'Starting from an empty board'); + expect(build(agents: 1).state, '1 agent on the board'); + expect(build(agents: 5).state, '5 agents on the board'); + expect(build(abilities: 1).state, '1 ability on the board'); + expect( + build(agents: 5, abilities: 12).state, + '5 agents · 12 abilities on the board', + ); + }); + }); +}