✨ Special display for spotify activity
This commit is contained in:
		@@ -467,10 +467,12 @@ final rpcServerStateProvider = StateNotifierProvider<
 | 
			
		||||
            activity['details'] ?? activity['assets']?['large_text'];
 | 
			
		||||
        var imageSmall = activity['assets']?['small_image'];
 | 
			
		||||
        var imageLarge = activity['assets']?['large_image'];
 | 
			
		||||
        if (imageSmall != null && !imageSmall!.contains(':'))
 | 
			
		||||
        if (imageSmall != null && !imageSmall!.contains(':')) {
 | 
			
		||||
          imageSmall = 'discord:$imageSmall';
 | 
			
		||||
        if (imageLarge != null && !imageLarge!.contains(':'))
 | 
			
		||||
        }
 | 
			
		||||
        if (imageLarge != null && !imageLarge!.contains(':')) {
 | 
			
		||||
          imageLarge = 'discord:$imageLarge';
 | 
			
		||||
        }
 | 
			
		||||
        try {
 | 
			
		||||
          final apiClient = ref.watch(apiClientProvider);
 | 
			
		||||
          final activityData = {
 | 
			
		||||
@@ -528,6 +530,13 @@ Future<List<SnPresenceActivity>> presenceActivities(
 | 
			
		||||
  Ref ref,
 | 
			
		||||
  String uname,
 | 
			
		||||
) async {
 | 
			
		||||
  ref.keepAlive();
 | 
			
		||||
  final timer = Timer.periodic(
 | 
			
		||||
    const Duration(minutes: 1),
 | 
			
		||||
    (_) => ref.invalidateSelf(),
 | 
			
		||||
  );
 | 
			
		||||
  ref.onDispose(() => timer.cancel());
 | 
			
		||||
 | 
			
		||||
  final apiClient = ref.watch(apiClientProvider);
 | 
			
		||||
  final response = await apiClient.get('/pass/activities/$uname');
 | 
			
		||||
  final data = response.data as List<dynamic>;
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ 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:island/widgets/content/image.dart';
 | 
			
		||||
import 'package:material_symbols_icons/symbols.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
import 'package:styled_widget/styled_widget.dart';
 | 
			
		||||
@@ -66,7 +67,7 @@ const kPresenceActivityIcons = <IconData>[
 | 
			
		||||
  Symbols.running_with_errors,
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
class ActivityPresenceWidget extends ConsumerWidget {
 | 
			
		||||
class ActivityPresenceWidget extends StatefulWidget {
 | 
			
		||||
  final String uname;
 | 
			
		||||
  final bool isCompact;
 | 
			
		||||
  final EdgeInsets compactPadding;
 | 
			
		||||
@@ -78,324 +79,526 @@ class ActivityPresenceWidget extends ConsumerWidget {
 | 
			
		||||
    this.compactPadding = EdgeInsets.zero,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  List<Widget> _buildDiscordImages(WidgetRef ref, SnPresenceActivity activity) {
 | 
			
		||||
  @override
 | 
			
		||||
  State<ActivityPresenceWidget> createState() => _ActivityPresenceWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _ActivityPresenceWidgetState extends State<ActivityPresenceWidget>
 | 
			
		||||
    with TickerProviderStateMixin {
 | 
			
		||||
  late AnimationController _progressController;
 | 
			
		||||
  late Animation<double> _progressAnimation;
 | 
			
		||||
  double _startProgress = 0.0;
 | 
			
		||||
  double _endProgress = 0.0;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _progressController = AnimationController(
 | 
			
		||||
      vsync: this,
 | 
			
		||||
      duration: const Duration(seconds: 1),
 | 
			
		||||
    );
 | 
			
		||||
    _progressAnimation = Tween<double>(
 | 
			
		||||
      begin: 0.0,
 | 
			
		||||
      end: 0.0,
 | 
			
		||||
    ).animate(_progressController);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _progressController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<Widget> _buildImages(WidgetRef ref, SnPresenceActivity activity) {
 | 
			
		||||
    final List<Widget> 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.largeImage != null) {
 | 
			
		||||
      if (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(),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        images.add(
 | 
			
		||||
          ClipRRect(
 | 
			
		||||
            borderRadius: BorderRadius.circular(8),
 | 
			
		||||
            child: UniversalImage(
 | 
			
		||||
              uri: activity.largeImage!,
 | 
			
		||||
              width: 64,
 | 
			
		||||
              height: 64,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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(),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    if (activity.smallImage != null) {
 | 
			
		||||
      if (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(),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        images.add(
 | 
			
		||||
          ClipRRect(
 | 
			
		||||
            borderRadius: BorderRadius.circular(8),
 | 
			
		||||
            child: UniversalImage(
 | 
			
		||||
              uri: activity.smallImage!,
 | 
			
		||||
              width: 32,
 | 
			
		||||
              height: 32,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return images;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final activitiesAsync = ref.watch(presenceActivitiesProvider(uname));
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Consumer(
 | 
			
		||||
      builder: (BuildContext context, WidgetRef ref, Widget? child) {
 | 
			
		||||
        final activitiesAsync = ref.watch(
 | 
			
		||||
          presenceActivitiesProvider(widget.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,
 | 
			
		||||
        if (widget.isCompact) {
 | 
			
		||||
          return activitiesAsync.when(
 | 
			
		||||
            data: (activities) {
 | 
			
		||||
              if (activities.isEmpty) return const SizedBox.shrink();
 | 
			
		||||
              final activity = activities.first;
 | 
			
		||||
              return Padding(
 | 
			
		||||
                padding: widget.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,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    )
 | 
			
		||||
                                    : const SizedBox.shrink(),
 | 
			
		||||
                        loading:
 | 
			
		||||
                            () => const SizedBox(
 | 
			
		||||
                                    ),
 | 
			
		||||
                                error:
 | 
			
		||||
                                    (error, stack) => const SizedBox.shrink(),
 | 
			
		||||
                              )
 | 
			
		||||
                          : ClipRRect(
 | 
			
		||||
                            borderRadius: BorderRadius.circular(4),
 | 
			
		||||
                            child: UniversalImage(
 | 
			
		||||
                              uri: activity.largeImage!,
 | 
			
		||||
                              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(
 | 
			
		||||
                            kPresenceActivityTypes[activity.type],
 | 
			
		||||
                          ).tr().fontSize(11),
 | 
			
		||||
                          Icon(
 | 
			
		||||
                            kPresenceActivityIcons[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(
 | 
			
		||||
                    Expanded(
 | 
			
		||||
                      child: 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: [
 | 
			
		||||
                            maxLines: 1,
 | 
			
		||||
                            overflow: TextOverflow.ellipsis,
 | 
			
		||||
                          ).fontSize(13),
 | 
			
		||||
                          Row(
 | 
			
		||||
                            spacing: 4,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text(kPresenceActivityTypes[activity.type]).tr(),
 | 
			
		||||
                              Text(
 | 
			
		||||
                                kPresenceActivityTypes[activity.type],
 | 
			
		||||
                              ).tr().fontSize(11),
 | 
			
		||||
                              Icon(
 | 
			
		||||
                                kPresenceActivityIcons[activity.type],
 | 
			
		||||
                                size: 16,
 | 
			
		||||
                                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);
 | 
			
		||||
                            },
 | 
			
		||||
                          ),
 | 
			
		||||
                          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')),
 | 
			
		||||
                    StreamBuilder(
 | 
			
		||||
                      stream: Stream.periodic(const Duration(seconds: 1)),
 | 
			
		||||
                      builder: (context, snapshot) {
 | 
			
		||||
                        final now = DateTime.now();
 | 
			
		||||
 | 
			
		||||
                        if (activity.manualId == 'spotify' &&
 | 
			
		||||
                            activity.meta != null) {
 | 
			
		||||
                          final meta = activity.meta as Map<String, dynamic>;
 | 
			
		||||
                          final progressMs = meta['progress_ms'] as int? ?? 0;
 | 
			
		||||
                          final durationMs =
 | 
			
		||||
                              meta['track_duration_ms'] as int? ?? 1;
 | 
			
		||||
                          final elapsed =
 | 
			
		||||
                              now.difference(activity.createdAt).inMilliseconds;
 | 
			
		||||
                          final currentProgressMs =
 | 
			
		||||
                              (progressMs + elapsed) % durationMs;
 | 
			
		||||
                          final progressValue = currentProgressMs / durationMs;
 | 
			
		||||
                          if (progressValue != _endProgress) {
 | 
			
		||||
                            _startProgress = _endProgress;
 | 
			
		||||
                            _endProgress = progressValue;
 | 
			
		||||
                            _progressAnimation = Tween<double>(
 | 
			
		||||
                              begin: _startProgress,
 | 
			
		||||
                              end: _endProgress,
 | 
			
		||||
                            ).animate(_progressController);
 | 
			
		||||
                            _progressController.forward(from: 0.0);
 | 
			
		||||
                          }
 | 
			
		||||
                          return AnimatedBuilder(
 | 
			
		||||
                            animation: _progressAnimation,
 | 
			
		||||
                            builder: (context, child) {
 | 
			
		||||
                              final animatedValue = _progressAnimation.value;
 | 
			
		||||
                              final animatedProgressMs =
 | 
			
		||||
                                  (animatedValue * durationMs).toInt();
 | 
			
		||||
                              final currentMin = animatedProgressMs ~/ 60000;
 | 
			
		||||
                              final currentSec =
 | 
			
		||||
                                  (animatedProgressMs % 60000) ~/ 1000;
 | 
			
		||||
                              return Column(
 | 
			
		||||
                                crossAxisAlignment: CrossAxisAlignment.end,
 | 
			
		||||
                                spacing: 2,
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  Text(
 | 
			
		||||
                                    '${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')}',
 | 
			
		||||
                                    style: TextStyle(
 | 
			
		||||
                                      fontSize: 10,
 | 
			
		||||
                                      color: Colors.green,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  SizedBox(
 | 
			
		||||
                                    width: 120,
 | 
			
		||||
                                    child: LinearProgressIndicator(
 | 
			
		||||
                                      value: animatedValue,
 | 
			
		||||
                                      backgroundColor: Colors.grey.shade300,
 | 
			
		||||
                                      stopIndicatorColor: Colors.green,
 | 
			
		||||
                                      trackGap: 0,
 | 
			
		||||
                                      valueColor: AlwaysStoppedAnimation<Color>(
 | 
			
		||||
                                        Colors.green,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ).padding(top: 2),
 | 
			
		||||
                                ],
 | 
			
		||||
                              );
 | 
			
		||||
                            },
 | 
			
		||||
                          );
 | 
			
		||||
                        } else {
 | 
			
		||||
                          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 = _buildImages(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),
 | 
			
		||||
                              Row(
 | 
			
		||||
                                spacing: 2,
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  Flexible(
 | 
			
		||||
                                    child: Text(
 | 
			
		||||
                                      (activity.title?.isEmpty ?? true)
 | 
			
		||||
                                          ? 'unknown'.tr()
 | 
			
		||||
                                          : activity.title!,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  if (activity.titleUrl != null &&
 | 
			
		||||
                                      activity.titleUrl!.isNotEmpty)
 | 
			
		||||
                                    IconButton(
 | 
			
		||||
                                      onPressed: () {
 | 
			
		||||
                                        launchUrlString(activity.titleUrl!);
 | 
			
		||||
                                      },
 | 
			
		||||
                                      icon: const Icon(Symbols.launch_rounded),
 | 
			
		||||
                                      iconSize: 16,
 | 
			
		||||
                                      padding: EdgeInsets.all(4),
 | 
			
		||||
                                      constraints: const BoxConstraints(
 | 
			
		||||
                                        maxWidth: 28,
 | 
			
		||||
                                        maxHeight: 28,
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                          subtitle: Column(
 | 
			
		||||
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Row(
 | 
			
		||||
                                spacing: 4,
 | 
			
		||||
                                children: [
 | 
			
		||||
                                  Text(
 | 
			
		||||
                                    kPresenceActivityTypes[activity.type],
 | 
			
		||||
                                  ).tr(),
 | 
			
		||||
                                  Icon(
 | 
			
		||||
                                    kPresenceActivityIcons[activity.type],
 | 
			
		||||
                                    size: 16,
 | 
			
		||||
                                    fill: 1,
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ],
 | 
			
		||||
                              ),
 | 
			
		||||
                              if (activity.manualId == 'spotify' &&
 | 
			
		||||
                                  activity.meta != null)
 | 
			
		||||
                                StreamBuilder(
 | 
			
		||||
                                  stream: Stream.periodic(
 | 
			
		||||
                                    const Duration(seconds: 1),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  builder: (context, snapshot) {
 | 
			
		||||
                                    final now = DateTime.now();
 | 
			
		||||
                                    final meta =
 | 
			
		||||
                                        activity.meta as Map<String, dynamic>;
 | 
			
		||||
                                    final progressMs =
 | 
			
		||||
                                        meta['progress_ms'] as int? ?? 0;
 | 
			
		||||
                                    final durationMs =
 | 
			
		||||
                                        meta['track_duration_ms'] as int? ?? 1;
 | 
			
		||||
                                    final elapsed =
 | 
			
		||||
                                        now
 | 
			
		||||
                                            .difference(activity.createdAt)
 | 
			
		||||
                                            .inMilliseconds;
 | 
			
		||||
                                    final currentProgressMs =
 | 
			
		||||
                                        (progressMs + elapsed) % durationMs;
 | 
			
		||||
                                    final progressValue =
 | 
			
		||||
                                        currentProgressMs / durationMs;
 | 
			
		||||
                                    if (progressValue != _endProgress) {
 | 
			
		||||
                                      _startProgress = _endProgress;
 | 
			
		||||
                                      _endProgress = progressValue;
 | 
			
		||||
                                      _progressAnimation = Tween<double>(
 | 
			
		||||
                                        begin: _startProgress,
 | 
			
		||||
                                        end: _endProgress,
 | 
			
		||||
                                      ).animate(_progressController);
 | 
			
		||||
                                      _progressController.forward(from: 0.0);
 | 
			
		||||
                                    }
 | 
			
		||||
                                    return AnimatedBuilder(
 | 
			
		||||
                                      animation: _progressAnimation,
 | 
			
		||||
                                      builder: (context, child) {
 | 
			
		||||
                                        final animatedValue =
 | 
			
		||||
                                            _progressAnimation.value;
 | 
			
		||||
                                        final animatedProgressMs =
 | 
			
		||||
                                            (animatedValue * durationMs)
 | 
			
		||||
                                                .toInt();
 | 
			
		||||
                                        final currentMin =
 | 
			
		||||
                                            animatedProgressMs ~/ 60000;
 | 
			
		||||
                                        final currentSec =
 | 
			
		||||
                                            (animatedProgressMs % 60000) ~/
 | 
			
		||||
                                            1000;
 | 
			
		||||
                                        final totalMin = durationMs ~/ 60000;
 | 
			
		||||
                                        final totalSec =
 | 
			
		||||
                                            (durationMs % 60000) ~/ 1000;
 | 
			
		||||
                                        return Column(
 | 
			
		||||
                                          crossAxisAlignment:
 | 
			
		||||
                                              CrossAxisAlignment.start,
 | 
			
		||||
                                          spacing: 4,
 | 
			
		||||
                                          children: [
 | 
			
		||||
                                            LinearProgressIndicator(
 | 
			
		||||
                                              value: animatedValue,
 | 
			
		||||
                                              backgroundColor:
 | 
			
		||||
                                                  Colors.grey.shade300,
 | 
			
		||||
                                              trackGap: 0,
 | 
			
		||||
                                              stopIndicatorColor: Colors.green,
 | 
			
		||||
                                              valueColor:
 | 
			
		||||
                                                  AlwaysStoppedAnimation<Color>(
 | 
			
		||||
                                                    Colors.green,
 | 
			
		||||
                                                  ),
 | 
			
		||||
                                            ).padding(top: 3),
 | 
			
		||||
                                            Text(
 | 
			
		||||
                                              '${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')} / ${totalMin.toString().padLeft(2, '0')}:${totalSec.toString().padLeft(2, '0')}',
 | 
			
		||||
                                              style: TextStyle(
 | 
			
		||||
                                                fontSize: 12,
 | 
			
		||||
                                                color: Colors.green,
 | 
			
		||||
                                              ),
 | 
			
		||||
                                            ),
 | 
			
		||||
                                          ],
 | 
			
		||||
                                        );
 | 
			
		||||
                                      },
 | 
			
		||||
                                    );
 | 
			
		||||
                                  },
 | 
			
		||||
                                )
 | 
			
		||||
                              else
 | 
			
		||||
                                StreamBuilder(
 | 
			
		||||
                                  stream: Stream.periodic(
 | 
			
		||||
                                    const Duration(seconds: 1),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                  builder: (context, snapshot) {
 | 
			
		||||
                                    final now = DateTime.now();
 | 
			
		||||
 | 
			
		||||
                                    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)
 | 
			
		||||
                                Row(
 | 
			
		||||
                                  spacing: 2,
 | 
			
		||||
                                  children: [
 | 
			
		||||
                                    Flexible(child: Text(activity.subtitle!)),
 | 
			
		||||
                                    if (activity.titleUrl != null &&
 | 
			
		||||
                                        activity.titleUrl!.isNotEmpty)
 | 
			
		||||
                                      IconButton(
 | 
			
		||||
                                        onPressed: () {
 | 
			
		||||
                                          launchUrlString(activity.titleUrl!);
 | 
			
		||||
                                        },
 | 
			
		||||
                                        icon: const Icon(
 | 
			
		||||
                                          Symbols.launch_rounded,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                        iconSize: 16,
 | 
			
		||||
                                        padding: EdgeInsets.all(4),
 | 
			
		||||
                                        constraints: const BoxConstraints(
 | 
			
		||||
                                          maxWidth: 28,
 | 
			
		||||
                                          maxHeight: 28,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              if (activity.caption?.isNotEmpty ?? false)
 | 
			
		||||
                                Text(activity.caption!),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ).padding(horizontal: 8);
 | 
			
		||||
                    }),
 | 
			
		||||
                  ],
 | 
			
		||||
                ).padding(horizontal: 8, top: 8, bottom: 16),
 | 
			
		||||
              ),
 | 
			
		||||
          loading: () => const Center(child: CircularProgressIndicator()),
 | 
			
		||||
          error:
 | 
			
		||||
              (error, stack) =>
 | 
			
		||||
                  Center(child: Text('Error loading activities: $error')),
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -36,8 +36,9 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
 | 
			
		||||
  Future<void> _checkIfSquare() async {
 | 
			
		||||
    if (widget.link.imageUrl == null ||
 | 
			
		||||
        widget.link.imageUrl!.isEmpty ||
 | 
			
		||||
        widget.link.imageUrl == widget.link.faviconUrl)
 | 
			
		||||
        widget.link.imageUrl == widget.link.faviconUrl) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final image = CachedNetworkImageProvider(widget.link.imageUrl!);
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user