import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/activity.dart'; import 'package:island/pods/activity/activity_rpc.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher_string.dart'; part 'activity_presence.g.dart'; @riverpod Future?> discordAssets( Ref ref, SnPresenceActivity activity, ) async { final hasDiscordSmall = activity.smallImage != null && activity.smallImage!.startsWith('discord:'); final hasDiscordLarge = activity.largeImage != null && activity.largeImage!.startsWith('discord:'); if (hasDiscordSmall || hasDiscordLarge) { final dio = Dio(); final response = await dio.get( 'https://discordapp.com/api/oauth2/applications/${activity.manualId}/assets', ); final data = response.data as List; return { for (final item in data) item['name'] as String: item['id'] as String, }; } return null; } @riverpod Future discordAssetsUrl( Ref ref, SnPresenceActivity activity, String key, ) async { final assets = await ref.watch(discordAssetsProvider(activity).future); if (assets != null && assets.containsKey(key)) { final assetId = assets[key]!; return 'https://cdn.discordapp.com/app-assets/${activity.manualId}/$assetId.png'; } return null; } const kPresenseActivityTypes = [ 'unknown', 'presenceTypeGaming', 'presenceTypeMusic', 'presenceTypeWorkout', ]; const kPresenseActivityIcons = [ Symbols.question_mark_rounded, Symbols.play_arrow_rounded, Symbols.music_note_rounded, Symbols.running_with_errors, ]; class ActivityPresenceWidget extends ConsumerWidget { final String uname; final bool isCompact; final EdgeInsets compactPadding; const ActivityPresenceWidget({ super.key, required this.uname, this.isCompact = false, this.compactPadding = EdgeInsets.zero, }); List _buildDiscordImages(WidgetRef ref, SnPresenceActivity activity) { final List images = []; if (activity.largeImage != null && activity.largeImage!.startsWith('discord:')) { final key = activity.largeImage!.substring('discord:'.length); final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key)); images.add( urlAsync.when( data: (url) => url != null ? ClipRRect( borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: url, width: 64, height: 64, ), ) : const SizedBox.shrink(), loading: () => const SizedBox( width: 64, height: 64, child: CircularProgressIndicator(strokeWidth: 2), ), error: (error, stack) => const SizedBox.shrink(), ), ); } if (activity.smallImage != null && activity.smallImage!.startsWith('discord:')) { final key = activity.smallImage!.substring('discord:'.length); final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key)); images.add( urlAsync.when( data: (url) => url != null ? ClipRRect( borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: url, width: 32, height: 32, ), ) : const SizedBox.shrink(), loading: () => const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), error: (error, stack) => const SizedBox.shrink(), ), ); } return images; } @override Widget build(BuildContext context, WidgetRef ref) { final activitiesAsync = ref.watch(presenceActivitiesProvider(uname)); if (isCompact) { return activitiesAsync.when( data: (activities) { if (activities.isEmpty) return const SizedBox.shrink(); final activity = activities.first; return Padding( padding: compactPadding, child: Row( spacing: 8, children: [ if (activity.largeImage != null && activity.largeImage!.startsWith('discord:')) ref .watch( discordAssetsUrlProvider( activity, activity.largeImage!.substring('discord:'.length), ), ) .when( data: (url) => url != null ? ClipRRect( borderRadius: BorderRadius.circular(4), child: CachedNetworkImage( imageUrl: url, width: 32, height: 32, ), ) : const SizedBox.shrink(), loading: () => const SizedBox( width: 32, height: 32, child: CircularProgressIndicator(strokeWidth: 1), ), error: (error, stack) => const SizedBox.shrink(), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( (activity.title?.isEmpty ?? true) ? 'unknown'.tr() : activity.title!, maxLines: 1, overflow: TextOverflow.ellipsis, ).fontSize(13), Row( children: [ Text( kPresenseActivityTypes[activity.type], ).tr().fontSize(11), Icon( kPresenseActivityIcons[activity.type], size: 15, fill: 1, ), ], ), ], ), ), StreamBuilder( stream: Stream.periodic(const Duration(seconds: 1)), builder: (context, snapshot) { final now = DateTime.now(); // Check if lease has expired and refresh if needed if (now.isAfter(activity.leaseExpiresAt)) { WidgetsBinding.instance.addPostFrameCallback((_) { ref.invalidate(presenceActivitiesProvider(uname)); }); } final duration = now.difference(activity.createdAt); final hours = duration.inHours.toString().padLeft(2, '0'); final minutes = (duration.inMinutes % 60) .toString() .padLeft(2, '0'); final seconds = (duration.inSeconds % 60) .toString() .padLeft(2, '0'); return Text( '$hours:$minutes:$seconds', ).textColor(Colors.green).fontSize(12); }, ), ], ), ); }, loading: () => const SizedBox.shrink(), error: (error, stack) => const SizedBox.shrink(), ); } return activitiesAsync.when( data: (activities) => Card( margin: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ Text( 'activities', ).tr().bold().padding(horizontal: 16, vertical: 4), if (activities.isEmpty) Row( spacing: 4, children: [ const Icon(Symbols.inbox, size: 16), Text('dataEmpty').tr().fontSize(13), ], ).opacity(0.75).padding(horizontal: 16, bottom: 8), ...activities.map((activity) { final dcImages = _buildDiscordImages(ref, activity); return Card( elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Colors.grey.shade300, width: 1), borderRadius: BorderRadius.circular(8), ), margin: EdgeInsets.zero, child: ListTile( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (dcImages.isNotEmpty) Row( crossAxisAlignment: CrossAxisAlignment.end, spacing: 8, children: dcImages, ).padding(vertical: 4), Text( (activity.title?.isEmpty ?? true) ? 'unknown'.tr() : activity.title!, ), ], ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( spacing: 4, children: [ Text(kPresenseActivityTypes[activity.type]).tr(), Icon( kPresenseActivityIcons[activity.type], size: 16, fill: 1, ), ], ), StreamBuilder( stream: Stream.periodic(const Duration(seconds: 1)), builder: (context, snapshot) { final now = DateTime.now(); // Check if lease has expired and refresh if needed if (now.isAfter(activity.leaseExpiresAt)) { WidgetsBinding.instance.addPostFrameCallback(( _, ) { ref.invalidate( presenceActivitiesProvider(uname), ); }); } final duration = now.difference( activity.createdAt, ); final hours = duration.inHours.toString().padLeft( 2, '0', ); final minutes = (duration.inMinutes % 60) .toString() .padLeft(2, '0'); final seconds = (duration.inSeconds % 60) .toString() .padLeft(2, '0'); return Text( '$hours:$minutes:$seconds', ).textColor(Colors.green); }, ), if (activity.subtitle?.isNotEmpty ?? false) Text(activity.subtitle!), if (activity.caption?.isNotEmpty ?? false) Text(activity.caption!), if ((activity.titleUrl?.isNotEmpty ?? false) || (activity.subtitleUrl?.isNotEmpty ?? false)) Row( spacing: 8, children: [ if (activity.titleUrl != null && activity.titleUrl!.isNotEmpty) ElevatedButton.icon( onPressed: () => launchUrlString(activity.titleUrl!), icon: const Icon(Symbols.link, size: 16), label: const Text('Open Title Link'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), textStyle: const TextStyle(fontSize: 12), ), ), if (activity.subtitleUrl != null && activity.subtitleUrl!.isNotEmpty) ElevatedButton.icon( onPressed: () => launchUrlString( activity.subtitleUrl!, ), icon: const Icon(Symbols.link, size: 16), label: const Text('Open Subtitle Link'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), textStyle: const TextStyle(fontSize: 12), ), ), ], ), ], ), ), ); }), ], ).padding(all: 8), ), loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center(child: Text('Error loading activities: $error')), ); } }