🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View File

@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/core/models/activitypub.dart';
import 'package:island/core/network.dart';
final activityPubServiceProvider = Provider<ActivityPubService>((ref) {
final client = ref.watch(apiClientProvider);
return ActivityPubService(client);
});
class ActivityPubService {
final Dio _client;
ActivityPubService(this._client);
Future<void> followRemoteUser(String targetActorUri) async {
final response = await _client.post(
'/sphere/activitypub/follow',
data: {'target_actor_uri': targetActorUri},
);
final followResponse = SnActivityPubFollowResponse.fromJson(response.data);
if (!followResponse.success) {
throw Exception(followResponse.message);
}
}
Future<void> unfollowRemoteUser(String targetActorUri) async {
final response = await _client.post(
'/sphere/activitypub/unfollow',
data: {'target_actor_uri': targetActorUri},
);
final followResponse = SnActivityPubFollowResponse.fromJson(response.data);
if (!followResponse.success) {
throw Exception(followResponse.message);
}
}
Future<List<SnActivityPubUser>> getFollowing({int limit = 50}) async {
final response = await _client.get(
'/sphere/activitypub/following',
queryParameters: {'limit': limit},
);
final users = (response.data as List<dynamic>)
.map((json) => SnActivityPubUser.fromJson(json))
.toList();
return users;
}
Future<List<SnActivityPubUser>> getFollowers({int limit = 50}) async {
final response = await _client.get(
'/sphere/activitypub/followers',
queryParameters: {'limit': limit},
);
final users = (response.data as List<dynamic>)
.map((json) => SnActivityPubUser.fromJson(json))
.toList();
return users;
}
Future<List<SnActivityPubActor>> searchUsers(
String query, {
int limit = 20,
}) async {
final response = await _client.get(
'/sphere/activitypub/search',
queryParameters: {'query': query, 'limit': limit},
);
final users = (response.data as List<dynamic>)
.map((json) => SnActivityPubActor.fromJson(json))
.toList();
return users;
}
Future<SnActorStatusResponse> getPublisherActorStatus(
String publisherName,
) async {
final response = await _client.get(
'/sphere/publishers/$publisherName/fediverse',
);
return SnActorStatusResponse.fromJson(response.data);
}
Future<void> enablePublisherActor(String publisherName) async {
await _client.post('/sphere/publishers/$publisherName/fediverse');
}
Future<void> disablePublisherActor(String publisherName) async {
await _client.delete('/sphere/publishers/$publisherName/fediverse');
}
}

View File

@@ -0,0 +1,536 @@
import 'dart:io';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:island/talker.dart';
class AnalyticsService {
static final AnalyticsService _instance = AnalyticsService._internal();
factory AnalyticsService() => _instance;
AnalyticsService._internal();
FirebaseAnalytics? _analytics;
bool _enabled = true;
bool get _supportsAnalytics =>
kIsWeb || (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
void initialize() {
if (!_supportsAnalytics) return;
try {
_analytics = FirebaseAnalytics.instance;
} catch (e) {
talker.warning('[Analytics] Failed to init: $e');
_analytics = null;
}
}
void logEvent(String name, Map<String, Object>? parameters) {
if (!_enabled || !_supportsAnalytics) return;
final analytics = _analytics;
if (analytics == null) return;
try {
analytics.logEvent(name: name, parameters: parameters);
} catch (e) {
talker.warning('[Analytics] Failed to log event $name: $e');
}
}
void setEnabled(bool enabled) {
_enabled = enabled;
}
void setUserId(String? id) {
if (!_supportsAnalytics) return;
final analytics = _analytics;
if (analytics == null) return;
try {
analytics.setUserId(id: id);
} catch (e) {
talker.warning('[Analytics] Failed to set user ID: $e');
}
}
void logAppOpen() {
logEvent('app_open', null);
}
void logLogin(String authMethod) {
logEvent('login', {'auth_method': authMethod, 'platform': _getPlatform()});
}
void logLogout() {
logEvent('logout', null);
}
void logPostViewed(String postId, String postType, String viewSource) {
logEvent('post_viewed', {
'post_id': postId,
'post_type': postType,
'view_source': viewSource,
});
}
void logPostCreated(
String postType,
String visibility,
bool hasAttachments,
String publisherId,
) {
logEvent('post_created', {
'post_type': postType,
'visibility': visibility,
'has_attachments': hasAttachments ? 'yes' : 'no',
'publisher_id': publisherId,
});
}
void logPostReacted(
String postId,
String reactionSymbol,
int attitude,
bool isRemoving,
) {
logEvent('post_reacted', {
'post_id': postId,
'reaction_symbol': reactionSymbol,
'attitude': attitude,
'is_removing': isRemoving ? 'yes' : 'no',
});
}
void logPostReplied(
String postId,
String parentId,
int characterCount,
bool hasAttachments,
) {
logEvent('post_replied', {
'post_id': postId,
'parent_id': parentId,
'character_count': characterCount,
'has_attachments': hasAttachments,
});
}
void logPostShared(String postId, String shareMethod, String postType) {
logEvent('post_shared', {
'post_id': postId,
'share_method': shareMethod,
'post_type': postType,
});
}
void logPostEdited(String postId, int contentChangeDelta) {
logEvent('post_edited', {
'post_id': postId,
'content_change_delta': contentChangeDelta,
});
}
void logPostDeleted(String postId, String postType, int timeSinceCreation) {
logEvent('post_deleted', {
'post_id': postId,
'post_type': postType,
'time_since_creation': timeSinceCreation,
});
}
void logPostPinned(String postId, String pinMode, String realmId) {
logEvent('post_pinned', {
'post_id': postId,
'pin_mode': pinMode,
'realm_id': realmId,
});
}
void logPostAwarded(
String postId,
double amount,
String attitude,
bool hasMessage,
) {
logEvent('post_awarded', {
'post_id': postId,
'amount': amount,
'attitude': attitude,
'has_message': hasMessage ? 'yes' : 'no',
'currency': 'NSP',
});
}
void logPostTranslated(
String postId,
String sourceLanguage,
String targetLanguage,
) {
logEvent('post_translated', {
'post_id': postId,
'source_language': sourceLanguage,
'target_language': targetLanguage,
});
}
void logPostForwarded(
String postId,
String originalPostId,
String publisherId,
) {
logEvent('post_forwarded', {
'post_id': postId,
'original_post_id': originalPostId,
'publisher_id': publisherId,
});
}
void logMessageSent(
String channelId,
String messageType,
bool hasAttachments,
int attachmentCount,
) {
logEvent('message_sent', {
'channel_id': channelId,
'message_type': messageType,
'has_attachments': hasAttachments,
'attachment_count': attachmentCount,
});
}
void logMessageReceived(
String channelId,
String messageType,
bool isMentioned,
) {
logEvent('message_received', {
'channel_id': channelId,
'message_type': messageType,
'is_mentioned': isMentioned,
});
}
void logMessageReplied(
String channelId,
String originalMessageId,
int replyDepth,
) {
logEvent('message_replied', {
'channel_id': channelId,
'original_message_id': originalMessageId,
'reply_depth': replyDepth,
});
}
void logMessageEdited(
String channelId,
String messageId,
int contentChangeDelta,
) {
logEvent('message_edited', {
'channel_id': channelId,
'message_id': messageId,
'content_change_delta': contentChangeDelta,
});
}
void logMessageDeleted(
String channelId,
String messageId,
String messageType,
bool isOwn,
) {
logEvent('message_deleted', {
'channel_id': channelId,
'message_id': messageId,
'message_type': messageType,
'is_own': isOwn,
});
}
void logChatRoomOpened(String channelId, String roomType) {
logEvent('chat_room_opened', {
'channel_id': channelId,
'room_type': roomType,
});
}
void logChatJoined(String channelId, String roomType, bool isPublic) {
logEvent('chat_joined', {
'channel_id': channelId,
'room_type': roomType,
'is_public': isPublic,
});
}
void logChatLeft(String channelId, String roomType) {
logEvent('chat_left', {'channel_id': channelId, 'room_type': roomType});
}
void logChatInvited(String channelId, String invitedUserId) {
logEvent('chat_invited', {
'channel_id': channelId,
'invited_user_id': invitedUserId,
});
}
void logFileUploaded(
String fileType,
String fileSizeCategory,
String uploadSource,
) {
logEvent('file_uploaded', {
'file_type': fileType,
'file_size_category': fileSizeCategory,
'upload_source': uploadSource,
});
}
void logFileDownloaded(
String fileType,
String fileSizeCategory,
String downloadMethod,
) {
logEvent('file_downloaded', {
'file_type': fileType,
'file_size_category': fileSizeCategory,
'download_method': downloadMethod,
});
}
void logFileDeleted(
String fileType,
String fileSizeCategory,
String deleteSource,
bool isBatchDelete,
int batchCount,
) {
logEvent('file_deleted', {
'file_type': fileType,
'file_size_category': fileSizeCategory,
'delete_source': deleteSource,
'is_batch_delete': isBatchDelete,
'batch_count': batchCount,
});
}
void logStickerUsed(String packId, String stickerSlug, String context) {
logEvent('sticker_used', {
'pack_id': packId,
'sticker_slug': stickerSlug,
'context': context,
});
}
void logStickerPackAdded(String packId, String packName, int stickerCount) {
logEvent('sticker_pack_added', {
'pack_id': packId,
'pack_name': packName,
'sticker_count': stickerCount,
});
}
void logStickerPackViewed(String packId, int stickerCount, bool isOwned) {
logEvent('sticker_pack_viewed', {
'pack_id': packId,
'sticker_count': stickerCount,
'is_owned': isOwned,
});
}
void logSearchPerformed(
String searchType,
String query,
int resultCount,
bool hasFilters,
) {
logEvent('search_performed', {
'search_type': searchType,
'query': query,
'result_count': resultCount,
'has_filters': hasFilters,
});
}
void logFeedSubscribed(String feedId, String feedUrl, int articleCount) {
logEvent('feed_subscribed', {
'feed_id': feedId,
'feed_url': feedUrl,
'article_count': articleCount,
});
}
void logFeedUnsubscribed(String feedId, String feedUrl) {
logEvent('feed_unsubscribed', {'feed_id': feedId, 'feed_url': feedUrl});
}
void logWalletTransfer(
double amount,
String currency,
String payeeId,
bool hasRemark,
) {
logEvent('wallet_transfer', {
'amount': amount,
'currency': currency,
'payee_id': payeeId,
'has_remark': hasRemark,
});
}
void logWalletBalanceChecked(List<String> currenciesViewed) {
logEvent('wallet_balance_checked', {
'currencies_viewed': currenciesViewed.join(','),
});
}
void logWalletOpened(String activeTab) {
logEvent('wallet_opened', {'active_tab': activeTab});
}
void logRealmJoined(String realmSlug, String realmType) {
logEvent('realm_joined', {
'realm_slug': realmSlug,
'realm_type': realmType,
});
}
void logRealmLeft(String realmSlug) {
logEvent('realm_left', {'realm_slug': realmSlug});
}
void logFriendAdded(String friendId, String pickerMethod) {
logEvent('friend_added', {
'friend_id': friendId,
'picker_method': pickerMethod,
});
}
void logFriendRemoved(String relationshipId, String relationshipType) {
logEvent('friend_removed', {
'relationship_id': relationshipId,
'relationship_type': relationshipType,
});
}
void logUserBlocked(String blockedUserId, String previousRelationship) {
logEvent('user_blocked', {
'blocked_user_id': blockedUserId,
'previous_relationship': previousRelationship,
});
}
void logUserUnblocked(String unblockedUserId) {
logEvent('user_unblocked', {'unblocked_user_id': unblockedUserId});
}
void logFriendRequestAccepted(String requesterId) {
logEvent('friend_request_accepted', {'requester_id': requesterId});
}
void logFriendRequestDeclined(String requesterId) {
logEvent('friend_request_declined', {'requester_id': requesterId});
}
void logThemeChanged(String oldMode, String newMode) {
logEvent('theme_changed', {'old_mode': oldMode, 'new_mode': newMode});
}
void logLanguageChanged(String oldLanguage, String newLanguage) {
logEvent('language_changed', {
'old_language': oldLanguage,
'new_language': newLanguage,
});
}
void logAiQuerySent(
int messageLength,
String contextType,
int attachedPostsCount,
) {
logEvent('ai_query_sent', {
'message_length': messageLength,
'context_type': contextType,
'attached_posts_count': attachedPostsCount,
});
}
void logAiResponseReceived(int responseThoughtCount, String sequenceId) {
logEvent('ai_response_received', {
'response_thought_count': responseThoughtCount,
'sequence_id': sequenceId,
});
}
void logShuffleViewed(int postIndex, int totalPostsLoaded) {
logEvent('shuffle_viewed', {
'post_index': postIndex,
'total_posts_loaded': totalPostsLoaded,
});
}
void logDraftSaved(String draftId, String postType, bool hasContent) {
logEvent('draft_saved', {
'draft_id': draftId,
'post_type': postType,
'has_content': hasContent,
});
}
void logDraftDeleted(String draftId, String postType) {
logEvent('draft_deleted', {'draft_id': draftId, 'post_type': postType});
}
void logCategoryViewed(String categorySlug, String categoryId) {
logEvent('category_viewed', {
'category_slug': categorySlug,
'category_id': categoryId,
});
}
void logTagViewed(String tagSlug, String tagId) {
logEvent('tag_viewed', {'tag_slug': tagSlug, 'tag_id': tagId});
}
void logCategorySubscribed(String categorySlug, String categoryId) {
logEvent('category_subscribed', {
'category_slug': categorySlug,
'category_id': categoryId,
});
}
void logTagSubscribed(String tagSlug, String tagId) {
logEvent('tag_subscribed', {'tag_slug': tagSlug, 'tag_id': tagId});
}
void logNotificationViewed() {
logEvent('notification_viewed', null);
}
void logNotificationActioned(String actionType, String notificationType) {
logEvent('notification_actioned', {
'action_type': actionType,
'notification_type': notificationType,
});
}
void logProfileUpdated(List<String> fieldsUpdated) {
logEvent('profile_updated', {'fields_updated': fieldsUpdated.join(',')});
}
void logAvatarChanged(String imageSource, bool isCropped) {
logEvent('avatar_changed', {
'image_source': imageSource,
'is_cropped': isCropped,
});
}
String _getPlatform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
if (Platform.isMacOS) return 'macos';
if (Platform.isWindows) return 'windows';
if (Platform.isLinux) return 'linux';
return 'web';
}
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter/widgets.dart';
extension ColorInversion on Color {
Color get invert {
return Color.fromARGB(alpha, 255 - red, 255 - green, 255 - blue);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import 'package:image/image.dart' as img;
import 'package:island/talker.dart';
import 'package:material_color_utilities/material_color_utilities.dart' as mcu;
class ColorExtractionService {
/// Extracts dominant colors from an image provider.
/// Returns a list of colors suitable for UI theming.
static Future<List<Color>> getColorsFromImage(ImageProvider provider) async {
try {
if (provider is FileImage) {
final bytes = await provider.file.readAsBytes();
final image = img.decodeImage(bytes);
if (image == null) return [];
final Map<int, int> colorToCount = {};
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
final pixel = image.getPixel(x, y);
final r = pixel.r.toInt();
final g = pixel.g.toInt();
final b = pixel.b.toInt();
final a = pixel.a.toInt();
if (a == 0) continue;
final argb = (a << 24) | (r << 16) | (g << 8) | b;
colorToCount[argb] = (colorToCount[argb] ?? 0) + 1;
}
}
final List<int> filteredResults = mcu.Score.score(
colorToCount,
desired: 1,
filter: true,
);
final List<int> scoredResults = mcu.Score.score(
colorToCount,
desired: 4,
filter: false,
);
return <dynamic>{
...filteredResults,
...scoredResults,
}.toList().map((argb) => Color(argb)).toList();
} else {
return [];
}
} catch (e, stackTrace) {
talker.error('Error getting colors from image...', e, stackTrace);
return [];
}
}
}

View File

@@ -0,0 +1,53 @@
import 'package:event_bus/event_bus.dart';
/// Global event bus instance for the application
final eventBus = EventBus();
/// Event fired when a post is successfully created
class PostCreatedEvent {
final String? postId;
final String? title;
final String? content;
const PostCreatedEvent({this.postId, this.title, this.content});
}
/// Event fired when chat rooms need to be refreshed
class ChatRoomsRefreshEvent {
const ChatRoomsRefreshEvent();
}
/// Event fired when OIDC auth callback is received
class OidcAuthCallbackEvent {
final String challengeId;
const OidcAuthCallbackEvent(this.challengeId);
}
/// Event fired to trigger the command palette
class CommandPaletteTriggerEvent {
const CommandPaletteTriggerEvent();
}
/// Event fired to show the compose post sheet
class ShowComposeSheetEvent {
const ShowComposeSheetEvent();
}
/// Event fired to show the notification sheet
class ShowNotificationSheetEvent {
const ShowNotificationSheetEvent();
}
/// Event fired to show the thought sheet
class ShowThoughtSheetEvent {
final String? initialMessage;
final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts;
const ShowThoughtSheetEvent({
this.initialMessage,
this.attachedMessages = const [],
this.attachedPosts = const [],
});
}

View File

@@ -0,0 +1,36 @@
import 'dart:ui';
import 'package:croppy/croppy.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
Future<XFile?> cropImage(
BuildContext context, {
required XFile image,
List<CropAspectRatio?>? allowedAspectRatios,
bool replacePath = true,
}) async {
if (!context.mounted) return null;
final imageBytes = await image.readAsBytes();
if (!context.mounted) return null;
final result = await showMaterialImageCropper(
context,
imageProvider: MemoryImage(imageBytes),
showLoadingIndicatorOnSubmit: true,
allowedAspectRatios: allowedAspectRatios,
);
if (result == null) return null; // Cancelled operation
final croppedFile = result.uiImage;
final croppedBytes = await croppedFile.toByteData(
format: ImageByteFormat.png,
);
if (croppedBytes == null) {
return image;
}
croppedFile.dispose();
return XFile.fromData(
croppedBytes.buffer.asUint8List(),
path: !replacePath ? image.path : null,
mimeType: image.mimeType,
);
}

View File

@@ -0,0 +1,60 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Conditional imports based on platform
import 'notify.windows.dart' as windows_notify;
import 'notify.universal.dart' as universal_notify;
// Platform-specific delegation
Future<void> initializeLocalNotifications() async {
if (kIsWeb) {
// No local notifications on web
return;
}
if (Platform.isWindows) {
return windows_notify.initializeLocalNotifications();
} else {
return universal_notify.initializeLocalNotifications();
}
}
StreamSubscription? setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
if (kIsWeb) {
// No notification listener on web
return null;
}
if (Platform.isWindows) {
return windows_notify.setupNotificationListener(context, ref);
} else {
return universal_notify.setupNotificationListener(context, ref);
}
}
Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (kIsWeb) {
// No push notification subscription on web
return;
}
if (Platform.isWindows) {
return windows_notify.subscribePushNotification(
apiClient,
detailedErrors: detailedErrors,
);
} else {
return universal_notify.subscribePushNotification(
apiClient,
detailedErrors: detailedErrors,
);
}
}

View File

@@ -0,0 +1,197 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/core/audio.dart';
import 'package:island/core/config.dart';
import 'package:island/core/notification.dart';
import 'package:island/route.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/websocket.dart';
import 'package:island/talker.dart';
import 'package:url_launcher/url_launcher_string.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
void _onAppLifecycleChanged(AppLifecycleState state) {
_appLifecycleState = state;
}
Future<void> initializeLocalNotifications() async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings();
const DarwinInitializationSettings initializationSettingsMacOS =
DarwinInitializationSettings();
const LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(defaultActionName: 'Open notification');
const WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Island',
appUserModelId: 'dev.solsynth.solian',
guid: 'dev.solsynth.solian',
);
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
macOS: initializationSettingsMacOS,
linux: initializationSettingsLinux,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: (NotificationResponse response) async {
final payload = response.payload;
if (payload != null) {
if (payload.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(payload);
} else {
// External URLs
launchUrlString(payload);
}
}
},
);
WidgetsBinding.instance.addObserver(
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
);
}
class LifecycleEventHandler extends WidgetsBindingObserver {
final void Function(AppLifecycleState) onAppLifecycleChanged;
LifecycleEventHandler({required this.onAppLifecycleChanged});
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onAppLifecycleChanged(state);
}
}
StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
final settings = ref.watch(appSettingsProvider);
final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) async {
if (pkt.type == "notifications.new") {
final notification = SnNotification.fromJson(pkt.data!);
if (_appLifecycleState == AppLifecycleState.resumed) {
talker.info(
'[Notification] Showing in-app notification: ${notification.title}',
);
if (settings.notifyWithHaptic) {
HapticFeedback.heavyImpact();
}
playNotificationSfx(ref);
ref.read(notificationStateProvider.notifier).add(notification);
} else {
// App is in background, show system notification (only on supported platforms)
if (!kIsWeb && !Platform.isIOS) {
talker.info(
'[Notification] Showing system notification: ${notification.title}',
);
// Use flutter_local_notifications for universal platforms
const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails(
'channel_id',
'channel_name',
channelDescription: 'channel_description',
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
const NotificationDetails notificationDetails = NotificationDetails(
android: androidNotificationDetails,
);
await flutterLocalNotificationsPlugin.show(
id: 0,
title: notification.title,
body: notification.content,
notificationDetails: notificationDetails,
payload: notification.meta['action_uri'] as String?,
);
} else {
talker.info(
'[Notification] Skipping system notification for unsupported platform: ${notification.title}',
);
}
}
}
});
}
Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (!kIsWeb && Platform.isLinux) {
return;
}
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
String? deviceToken;
if (kIsWeb) {
deviceToken = await FirebaseMessaging.instance.getToken(
vapidKey:
"BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU",
);
} else if (Platform.isAndroid) {
deviceToken = await FirebaseMessaging.instance.getToken();
} else if (Platform.isIOS) {
deviceToken = await FirebaseMessaging.instance.getAPNSToken();
}
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) {
_putTokenToRemote(apiClient, fcmToken, 1);
})
.onError((err) {
talker.error("Failed to get firebase cloud messaging push token: $err");
});
if (deviceToken != null) {
_putTokenToRemote(
apiClient,
deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
);
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
}
}
Future<void> _putTokenToRemote(
Dio apiClient,
String token,
int provider,
) async {
await apiClient.put(
"/ring/notifications/subscription",
data: {"provider": provider, "device_token": token},
);
}

View File

@@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/core/audio.dart';
import 'package:island/core/config.dart';
import 'package:island/core/notification.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/websocket.dart';
import 'package:island/talker.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:windows_notification/windows_notification.dart' as winty;
import 'package:windows_notification/notification_message.dart';
// Windows notification instance
winty.WindowsNotification? windowsNotification;
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
void _onAppLifecycleChanged(AppLifecycleState state) {
_appLifecycleState = state;
}
Future<void> initializeLocalNotifications() async {
// Initialize Windows notification for Windows platform
windowsNotification = winty.WindowsNotification(applicationId: "Solian");
WidgetsBinding.instance.addObserver(
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
);
}
class LifecycleEventHandler extends WidgetsBindingObserver {
final void Function(AppLifecycleState) onAppLifecycleChanged;
LifecycleEventHandler({required this.onAppLifecycleChanged});
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onAppLifecycleChanged(state);
}
}
StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
final settings = ref.watch(appSettingsProvider);
final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) async {
if (pkt.type == "notifications.new") {
final notification = SnNotification.fromJson(pkt.data!);
if (_appLifecycleState == AppLifecycleState.resumed) {
talker.info(
'[Notification] Showing in-app notification: ${notification.title}',
);
if (settings.notifyWithHaptic) {
HapticFeedback.heavyImpact();
}
playNotificationSfx(ref);
ref.read(notificationStateProvider.notifier).add(notification);
} else {
// App is in background, show Windows system notification
talker.info(
'[Notification] Showing Windows system notification: ${notification.title}',
);
if (windowsNotification != null) {
final serverUrl = ref.read(serverUrlProvider);
final pfp = notification.meta['pfp'] as String?;
final img = notification.meta['images'] as List<dynamic>?;
final actionUrl = notification.meta['action_uri'] as String?;
// Download and cache images
String? imagePath;
String? largeImagePath;
if (pfp != null) {
try {
final file = await DefaultCacheManager().getSingleFile(
'$serverUrl/drive/files/$pfp',
);
imagePath = file.path;
} catch (e) {
talker.error('Failed to download pfp image: $e');
}
}
if (img != null && img.isNotEmpty) {
try {
final file = await DefaultCacheManager().getSingleFile(
'$serverUrl/drive/files/${img.firstOrNull}',
);
largeImagePath = file.path;
} catch (e) {
talker.error('Failed to download large image: $e');
}
}
// Use Windows notification for Windows platform
final notificationMessage = NotificationMessage.fromPluginTemplate(
notification.id, // unique id
notification.title,
[
notification.subtitle,
notification.content,
].where((e) => e.isNotEmpty).join('\n'),
group: notification.topic,
image: imagePath,
largeImage: largeImagePath,
launch: actionUrl != null ? 'solian://$actionUrl' : null,
);
await windowsNotification!.showNotificationPluginTemplate(
notificationMessage,
);
}
}
}
});
}
Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
if (!kIsWeb && Platform.isLinux) {
return;
}
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
String? deviceToken;
if (kIsWeb) {
deviceToken = await FirebaseMessaging.instance.getToken(
vapidKey:
"BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU",
);
} else if (Platform.isAndroid) {
deviceToken = await FirebaseMessaging.instance.getToken();
} else if (Platform.isIOS) {
deviceToken = await FirebaseMessaging.instance.getAPNSToken();
}
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) {
_putTokenToRemote(apiClient, fcmToken, 1);
})
.onError((err) {
talker.error("Failed to get firebase cloud messaging push token: $err");
});
if (deviceToken != null) {
_putTokenToRemote(
apiClient,
deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
);
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
}
}
Future<void> _putTokenToRemote(
Dio apiClient,
String token,
int provider,
) async {
await apiClient.put(
"/ring/notifications/subscription",
data: {"provider": provider, "device_token": token},
);
}

View File

@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:island/route.dart';
import 'package:island/core/services/event_bus.dart';
import 'package:island/talker.dart';
import 'package:quick_actions/quick_actions.dart';
class QuickActionsService {
static final QuickActionsService _instance = QuickActionsService._internal();
factory QuickActionsService() => _instance;
QuickActionsService._internal();
final QuickActions _quickActions = const QuickActions();
bool _initialized = false;
Future<void> initialize() async {
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
talker.warning(
'[QuickActions] Quick Actions only supported on Android and iOS',
);
return;
}
if (_initialized) {
talker.info('[QuickActions] Already initialized');
return;
}
try {
talker.info('[QuickActions] Initializing Quick Actions...');
// TODO Add icons for these
final shortcuts = <ShortcutItem>[
const ShortcutItem(type: 'compose_post', localizedTitle: 'New Post'),
const ShortcutItem(type: 'explore', localizedTitle: 'Explore'),
const ShortcutItem(type: 'chats', localizedTitle: 'Chats'),
const ShortcutItem(
type: 'notifications',
localizedTitle: 'Notifications',
),
];
await _quickActions.initialize(_handleShortcut);
await _quickActions.setShortcutItems(shortcuts);
_initialized = true;
talker.info('[QuickActions] Quick Actions initialized successfully');
} catch (e, stack) {
talker.error('[QuickActions] Initialization failed', e, stack);
rethrow;
}
}
void _handleShortcut(String type) {
talker.info('[QuickActions] Shortcut tapped: $type');
final context = rootNavigatorKey.currentContext;
if (context == null) {
talker.warning('[QuickActions] Context not available, skipping action');
return;
}
switch (type) {
case 'compose_post':
eventBus.fire(const ShowComposeSheetEvent());
break;
case 'explore':
context.go('/explore');
break;
case 'chats':
context.go('/chat');
break;
case 'notifications':
context.go('/notifications');
break;
default:
talker.warning('[QuickActions] Unknown shortcut type: $type');
}
}
void dispose() {
_initialized = false;
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';
const kWideScreenWidth = 768.0;
const kWiderScreenWidth = 1024.0;
const kWidescreenWidth = 1280.0;
bool isWideScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWideScreenWidth;
}
bool isWiderScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWiderScreenWidth;
}
bool isWidestScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWidescreenWidth;
}

View File

@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:island/core/widgets/share/share_sheet.dart';
import 'package:share_plus/share_plus.dart';
class SharingIntentService {
static final SharingIntentService _instance =
SharingIntentService._internal();
factory SharingIntentService() => _instance;
SharingIntentService._internal();
StreamSubscription<List<SharedMediaFile>>? _intentSub;
BuildContext? _context;
/// Initialize the sharing intent service
void initialize(BuildContext context) {
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return;
debugPrint("SharingIntentService: Initializing with context");
_context = context;
_setupSharingListeners();
}
/// Setup listeners for sharing intents
void _setupSharingListeners() {
debugPrint("SharingIntentService: Setting up sharing listeners");
// Listen to media sharing coming from outside the app while the app is in memory
_intentSub = ReceiveSharingIntent.instance.getMediaStream().listen(
(List<SharedMediaFile> value) {
debugPrint(
"SharingIntentService: Media stream received ${value.length} files",
);
if (value.isNotEmpty) {
_handleSharedContent(value);
}
},
onError: (err) {
debugPrint("SharingIntentService: Stream error: $err");
},
);
// Get the media sharing coming from outside the app while the app is closed
ReceiveSharingIntent.instance.getInitialMedia().then((
List<SharedMediaFile> value,
) {
debugPrint(
"SharingIntentService: Initial media received ${value.length} files",
);
if (value.isNotEmpty) {
_handleSharedContent(value);
// Tell the library that we are done processing the intent
ReceiveSharingIntent.instance.reset();
}
});
}
/// Handle shared media files
void _handleSharedContent(List<SharedMediaFile> sharedFiles) {
if (_context == null) {
debugPrint(
"SharingIntentService: Context is null, cannot handle shared content",
);
return;
}
debugPrint(
"SharingIntentService: Received ${sharedFiles.length} shared files",
);
for (final file in sharedFiles) {
debugPrint(
"SharingIntentService: File path: ${file.path}, type: ${file.type}",
);
}
// Convert SharedMediaFile to XFile for files
final List<XFile> files =
sharedFiles
.where(
(file) =>
file.type == SharedMediaType.file ||
file.type == SharedMediaType.video ||
file.type == SharedMediaType.image,
)
.map((file) => XFile(file.path, name: file.path.split('/').last))
.toList();
// Extract links from shared content
final List<String> links =
sharedFiles
.where((file) => file.type == SharedMediaType.url)
.map((file) => file.path)
.toList();
// Show ShareSheet with the shared files
if (files.isNotEmpty) {
showShareSheet(context: _context!, content: ShareContent.files(files));
} else if (links.isNotEmpty) {
showShareSheet(
context: _context!,
content: ShareContent.link(links.first),
);
} else {
showShareSheet(
context: _context!,
content: ShareContent.text(
sharedFiles
.where((file) => file.type == SharedMediaType.text)
.map((text) => text.message)
.join('\n'),
),
);
}
}
/// Dispose of resources
void dispose() {
_intentSub?.cancel();
_context = null;
}
}

103
lib/core/services/time.dart Normal file
View File

@@ -0,0 +1,103 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:relative_time/relative_time.dart';
extension DurationFormatter on Duration {
String formatDuration() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours.toString().padLeft(2, '0');
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
final seconds = (positiveDuration.inSeconds % 60).toString().padLeft(
2,
'0',
);
return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
}
String formatShortDuration() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours;
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
final seconds = (positiveDuration.inSeconds % 60).toString().padLeft(
2,
'0',
);
final milliseconds = (positiveDuration.inMilliseconds % 1000)
.toString()
.padLeft(3, '0');
String result;
if (hours > 0) {
result =
'${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds';
} else {
result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds';
}
return result;
}
String formatOffset() {
final isNegative = inMicroseconds < 0;
final positiveDuration = isNegative ? -this : this;
final hours = positiveDuration.inHours.toString().padLeft(2, '0');
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
return '${isNegative ? '-' : '+'}$hours:$minutes';
}
String formatOffsetLocal() {
// Get the local timezone offset
final localOffset = DateTime.now().timeZoneOffset;
// Add the local offset to the input duration
final totalOffset = this - localOffset;
final isNegative = totalOffset.inMicroseconds < 0;
final positiveDuration = isNegative ? -totalOffset : totalOffset;
final hours = positiveDuration.inHours.toString().padLeft(2, '0');
final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
2,
'0',
);
return '${isNegative ? '-' : '+'}$hours:$minutes';
}
}
extension DateTimeFormatter on DateTime {
String formatSystem() {
return DateFormat.yMd().add_jm().format(toLocal());
}
String formatCustom(String pattern) {
return DateFormat(pattern).format(toLocal());
}
String formatCustomGlobal(String pattern) {
return DateFormat(pattern).format(this);
}
String formatWithLocale(String locale) {
return DateFormat.yMd().add_jm().format(toLocal()).toString();
}
String formatRelative(BuildContext context) {
return RelativeTime(context).format(toLocal());
}
}

View File

@@ -0,0 +1 @@
export 'timezone/native.dart' if (dart.library.html) 'timezone/web.dart';

View File

@@ -0,0 +1,22 @@
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/standalone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tzdb;
Future<void> initializeTzdb() async {
tzdb.initializeTimeZones();
}
(Duration offset, DateTime now) getTzInfo(String name) {
final location = tz.getLocation(name);
final now = tz.TZDateTime.now(location);
final offset = now.timeZoneOffset;
return (offset, now);
}
Future<String> getMachineTz() async {
return (await FlutterTimezone.getLocalTimezone()).identifier;
}
List<String> getAvailableTz() {
return tz.timeZoneDatabase.locations.keys.toList();
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:timezone/browser.dart' as tz;
Future<void> initializeTzdb() async {
await tz.initializeTimeZone();
}
(Duration offset, DateTime now) getTzInfo(String name) {
final location = tz.getLocation(name);
final now = tz.TZDateTime.now(location);
final offset = now.timeZoneOffset;
return (offset, now);
}
Future<String> getMachineTz() async {
return (await FlutterTimezone.getLocalTimezone()).identifier;
}
List<String> getAvailableTz() {
return tz.timeZoneDatabase.locations.keys.toList();
}

View File

@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/core/config.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tour.g.dart';
part 'tour.freezed.dart';
const kAppTourStatusKey = "app_tour_statuses";
const List<Tour> kAllTours = [
// Tour(id: 'technical_review_intro', isStartup: true),
];
@freezed
sealed class Tour with _$Tour {
const Tour._();
const factory Tour({required String id, required bool isStartup}) = _Tour;
Widget get widget => switch (id) {
// 'technical_review_intro' => const TechicalReviewIntroWidget(),
_ => throw UnimplementedError(),
};
}
@riverpod
class TourStatusNotifier extends _$TourStatusNotifier {
@override
Map<String, bool> build() {
final prefs = ref.watch(sharedPreferencesProvider);
final storedJson = prefs.getString(kAppTourStatusKey);
if (storedJson != null) {
try {
final Map<String, dynamic> stored = jsonDecode(storedJson);
return Map<String, bool>.from(stored);
} catch (_) {
return {for (final id in kAllTours.map((e) => e.id)) id: false};
}
}
return {for (final id in kAllTours.map((e) => e.id)) id: false};
}
bool isTourShown(String tourId) => state[tourId] ?? false;
Future<void> _saveState(Map<String, bool> newState) async {
state = newState;
final prefs = ref.read(sharedPreferencesProvider);
await prefs.setString(kAppTourStatusKey, jsonEncode(newState));
}
Future<Widget?> showTour(String tourId) async {
if (!isTourShown(tourId)) {
final newState = {...state, tourId: true};
await _saveState(newState);
return kAllTours.firstWhere((e) => e.id == tourId).widget;
}
return null;
}
Future<void> resetTours() async {
final newState = {for (final id in kAllTours.map((e) => e.id)) id: false};
await _saveState(newState);
}
}

View File

@@ -0,0 +1,268 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'tour.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Tour {
String get id; bool get isStartup;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$TourCopyWith<Tour> get copyWith => _$TourCopyWithImpl<Tour>(this as Tour, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Tour&&(identical(other.id, id) || other.id == id)&&(identical(other.isStartup, isStartup) || other.isStartup == isStartup));
}
@override
int get hashCode => Object.hash(runtimeType,id,isStartup);
@override
String toString() {
return 'Tour(id: $id, isStartup: $isStartup)';
}
}
/// @nodoc
abstract mixin class $TourCopyWith<$Res> {
factory $TourCopyWith(Tour value, $Res Function(Tour) _then) = _$TourCopyWithImpl;
@useResult
$Res call({
String id, bool isStartup
});
}
/// @nodoc
class _$TourCopyWithImpl<$Res>
implements $TourCopyWith<$Res> {
_$TourCopyWithImpl(this._self, this._then);
final Tour _self;
final $Res Function(Tour) _then;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? isStartup = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,isStartup: null == isStartup ? _self.isStartup : isStartup // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [Tour].
extension TourPatterns on Tour {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Tour value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Tour value) $default,){
final _that = this;
switch (_that) {
case _Tour():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Tour value)? $default,){
final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, bool isStartup)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that.id,_that.isStartup);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, bool isStartup) $default,) {final _that = this;
switch (_that) {
case _Tour():
return $default(_that.id,_that.isStartup);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, bool isStartup)? $default,) {final _that = this;
switch (_that) {
case _Tour() when $default != null:
return $default(_that.id,_that.isStartup);case _:
return null;
}
}
}
/// @nodoc
class _Tour extends Tour {
const _Tour({required this.id, required this.isStartup}): super._();
@override final String id;
@override final bool isStartup;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$TourCopyWith<_Tour> get copyWith => __$TourCopyWithImpl<_Tour>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Tour&&(identical(other.id, id) || other.id == id)&&(identical(other.isStartup, isStartup) || other.isStartup == isStartup));
}
@override
int get hashCode => Object.hash(runtimeType,id,isStartup);
@override
String toString() {
return 'Tour(id: $id, isStartup: $isStartup)';
}
}
/// @nodoc
abstract mixin class _$TourCopyWith<$Res> implements $TourCopyWith<$Res> {
factory _$TourCopyWith(_Tour value, $Res Function(_Tour) _then) = __$TourCopyWithImpl;
@override @useResult
$Res call({
String id, bool isStartup
});
}
/// @nodoc
class __$TourCopyWithImpl<$Res>
implements _$TourCopyWith<$Res> {
__$TourCopyWithImpl(this._self, this._then);
final _Tour _self;
final $Res Function(_Tour) _then;
/// Create a copy of Tour
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? isStartup = null,}) {
return _then(_Tour(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,isStartup: null == isStartup ? _self.isStartup : isStartup // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tour.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TourStatusNotifier)
final tourStatusProvider = TourStatusNotifierProvider._();
final class TourStatusNotifierProvider
extends $NotifierProvider<TourStatusNotifier, Map<String, bool>> {
TourStatusNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tourStatusProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tourStatusNotifierHash();
@$internal
@override
TourStatusNotifier create() => TourStatusNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, bool> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, bool>>(value),
);
}
}
String _$tourStatusNotifierHash() =>
r'ee712e1f8010311df8f24838814ab5c451f9e593';
abstract class _$TourStatusNotifier extends $Notifier<Map<String, bool>> {
Map<String, bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Map<String, bool>, Map<String, bool>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, bool>, Map<String, bool>>,
Map<String, bool>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,3 @@
export 'udid.native.dart'
if (dart.library.html) 'udid.web.dart'
if (dart.library.io) 'udid.native.dart';

View File

@@ -0,0 +1,29 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_udid/flutter_udid.dart';
String? _cachedUdid;
Future<String> getUdid() async {
if (_cachedUdid != null) {
return _cachedUdid!;
}
_cachedUdid = await FlutterUdid.consistentUdid;
return _cachedUdid!;
}
Future<String> getDeviceName() async {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
return androidInfo.device;
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
return iosInfo.name;
} else if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
return Platform.localHostname;
} else {
return 'unknown'.tr();
}
}

View File

@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:web/web.dart';
Future<String> getUdid() async {
final userAgent = window.navigator.userAgent;
final bytes = utf8.encode(userAgent);
final hash = sha256.convert(bytes);
return hash.toString();
}
Future<String> getDeviceName() async {
final userAgent = window.navigator.userAgent;
if (userAgent.contains('Chrome') && !userAgent.contains('Edg')) {
return 'Chrome';
} else if (userAgent.contains('Firefox')) {
return 'Firefox';
} else if (userAgent.contains('Safari') && !userAgent.contains('Chrome')) {
return 'Safari';
} else if (userAgent.contains('Edg')) {
return 'Edge';
} else {
return 'Browser';
}
}

View File

@@ -0,0 +1,725 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_update/azhon_app_update.dart';
import 'package:flutter_app_update/update_model.dart';
import 'package:island/core/widgets/content/markdown.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:process_run/process_run.dart';
import 'package:collection/collection.dart'; // Added for firstWhereOrNull
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/talker.dart';
/// Data model for a GitHub release we care about
class GithubReleaseInfo {
final String tagName;
final String name;
final String body;
final String htmlUrl;
final DateTime createdAt;
final List<GithubReleaseAsset> assets;
const GithubReleaseInfo({
required this.tagName,
required this.name,
required this.body,
required this.htmlUrl,
required this.createdAt,
this.assets = const [],
});
}
/// Data model for a GitHub release asset
class GithubReleaseAsset {
final String name;
final String browserDownloadUrl;
const GithubReleaseAsset({
required this.name,
required this.browserDownloadUrl,
});
factory GithubReleaseAsset.fromJson(Map<String, dynamic> json) {
return GithubReleaseAsset(
name: json['name'] as String,
browserDownloadUrl: json['browser_download_url'] as String,
);
}
}
/// Parses version and build number from "x.y.z+build"
class _ParsedVersion implements Comparable<_ParsedVersion> {
final int major;
final int minor;
final int patch;
final int build;
const _ParsedVersion(this.major, this.minor, this.patch, this.build);
static _ParsedVersion? tryParse(String input) {
// Expect format like 0.0.0+00 (build after '+'). Allow missing build as 0.
final partsPlus = input.split('+');
final core = partsPlus[0].trim();
final buildStr = partsPlus.length > 1 ? partsPlus[1].trim() : '0';
final coreParts = core.split('.');
if (coreParts.length != 3) return null;
final major = int.tryParse(coreParts[0]) ?? 0;
final minor = int.tryParse(coreParts[1]) ?? 0;
final patch = int.tryParse(coreParts[2]) ?? 0;
final build = int.tryParse(buildStr) ?? 0;
return _ParsedVersion(major, minor, patch, build);
}
/// Normalize Android build numbers by removing architecture-based offsets
/// Android adds 1000 for x86, 2000 for ARMv7, 4000 for ARMv8
int get normalizedBuild {
// Check if build number has an architecture offset
// We detect this by checking if the build % 1000 is the base build
if (build >= 4000) {
// Likely ARMv8 (arm64-v8a) with +4000 offset
return build % 4000;
} else if (build >= 2000) {
// Likely ARMv7 (armeabi-v7a) with +2000 offset
return build % 2000;
} else if (build >= 1000) {
// Likely x86/x86_64 with +1000 offset
return build % 1000;
}
// No offset, return as-is
return build;
}
@override
int compareTo(_ParsedVersion other) {
if (major != other.major) return major.compareTo(other.major);
if (minor != other.minor) return minor.compareTo(other.minor);
if (patch != other.patch) return patch.compareTo(other.patch);
// Use normalized build numbers for comparison to handle Android arch offsets
return normalizedBuild.compareTo(other.normalizedBuild);
}
@override
String toString() => '$major.$minor.$patch+$build';
}
class UpdateService {
UpdateService({Dio? dio, this.useProxy = false})
: _dio =
dio ??
Dio(
BaseOptions(
headers: {
// Identify the app to GitHub; avoids some rate-limits and adds clarity
'Accept': 'application/vnd.github+json',
'User-Agent': 'solian-update-checker',
},
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
),
);
final Dio _dio;
final bool useProxy;
static const _proxyBaseUrl = 'https://ghfast.top/';
static const _releasesLatestApi =
'https://api.github.com/repos/solsynth/solian/releases/latest';
/// Checks GitHub for the latest release and compares against the current app version.
/// If update is available, shows a bottom sheet with changelog and an action to open release page.
Future<void> checkForUpdates(BuildContext context) async {
talker.info('[Update] Checking for updates...');
try {
final release = await fetchLatestRelease();
if (release == null) {
talker.info('[Update] No latest release found or could not fetch.');
return;
}
talker.info('[Update] Fetched latest release: ${release.tagName}');
final info = await PackageInfo.fromPlatform();
final localVersionStr = '${info.version}+${info.buildNumber}';
talker.info('[Update] Local app version: $localVersionStr');
final latest = _ParsedVersion.tryParse(release.tagName);
final local = _ParsedVersion.tryParse(localVersionStr);
if (latest == null || local == null) {
talker.info(
'[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr',
);
// If parsing fails, do nothing silently
return;
}
talker.info('[Update] Parsed versions. Latest: $latest, Local: $local');
final needsUpdate = latest.compareTo(local) > 0;
if (!needsUpdate) {
talker.info('[Update] App is up to date. No update needed.');
return;
}
talker.info('[Update] Update available! Latest: $latest, Local: $local');
if (!context.mounted) {
talker.info('[Update] Context not mounted, cannot show update sheet.');
return;
}
// Delay to ensure UI is ready (if called at startup)
await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted) {
await showUpdateSheet(context, release);
talker.info('[Update] Update sheet shown.');
}
} catch (e) {
talker.error('[Update] Error checking for updates: $e');
// Ignore errors (network, api, etc.)
return;
}
}
/// Manually show the update sheet with a provided release.
/// Useful for About page or testing.
Future<void> showUpdateSheet(
BuildContext context,
GithubReleaseInfo release,
) async {
if (!context.mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (ctx) {
String? androidUpdateUrl;
String? windowsUpdateUrl;
if (Platform.isAndroid) {
androidUpdateUrl = _getAndroidUpdateUrl(release.assets);
}
if (Platform.isWindows) {
windowsUpdateUrl = _getWindowsUpdateUrl();
}
return _UpdateSheet(
release: release,
onOpen: () async {
final uri = Uri.parse(release.htmlUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
androidUpdateUrl: androidUpdateUrl,
windowsUpdateUrl: windowsUpdateUrl,
useProxy: useProxy, // Pass the useProxy flag
);
},
);
}
String? _getAndroidUpdateUrl(List<GithubReleaseAsset> assets) {
final arm64 = assets.firstWhereOrNull(
(asset) => asset.name == 'app-arm64-v8a-release.apk',
);
final armeabi = assets.firstWhereOrNull(
(asset) => asset.name == 'app-armeabi-v7a-release.apk',
);
final x86_64 = assets.firstWhereOrNull(
(asset) => asset.name == 'app-x86_64-release.apk',
);
// Prioritize arm64, then armeabi, then x86_64
if (arm64 != null) {
return 'https://fs.solsynth.dev/d/official/solian/${arm64.name}';
} else if (armeabi != null) {
return 'https://fs.solsynth.dev/d/official/solian/${armeabi.name}';
} else if (x86_64 != null) {
return 'https://fs.solsynth.dev/d/official/solian/${x86_64.name}';
}
return null;
}
String _getWindowsUpdateUrl() {
return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip';
}
/// Performs automatic Windows update: download, extract, and install
Future<void> performAutomaticWindowsUpdate(
BuildContext context,
String url,
) async {
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder:
(context) => _WindowsUpdateDialog(
updateUrl: url,
onComplete: () {
// Close the update sheet
Navigator.of(context).pop();
},
),
);
}
/// Fetch the latest release info from GitHub.
/// Public so other screens (e.g., About) can manually trigger update checks.
Future<GithubReleaseInfo?> fetchLatestRelease() async {
final apiEndpoint =
useProxy
? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
: _releasesLatestApi;
talker.info(
'[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)',
);
final resp = await _dio.get(apiEndpoint);
if (resp.statusCode != 200) {
talker.error(
'[Update] Failed to fetch latest release. Status code: ${resp.statusCode}',
);
return null;
}
final data = resp.data as Map<String, dynamic>;
talker.info('[Update] Successfully fetched release data.');
final tagName = (data['tag_name'] ?? '').toString();
final name = (data['name'] ?? tagName).toString();
final body = (data['body'] ?? '').toString();
final htmlUrl = (data['html_url'] ?? '').toString();
final createdAtStr = (data['created_at'] ?? '').toString();
final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
final assetsData =
(data['assets'] as List<dynamic>?)
?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
if (tagName.isEmpty || htmlUrl.isEmpty) {
talker.error(
'[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"',
);
return null;
}
talker.info('[Update] Returning GithubReleaseInfo for tag: $tagName');
return GithubReleaseInfo(
tagName: tagName,
name: name,
body: body,
htmlUrl: htmlUrl,
createdAt: createdAt,
assets: assetsData,
);
}
}
class _WindowsUpdateDialog extends StatefulWidget {
const _WindowsUpdateDialog({
required this.updateUrl,
required this.onComplete,
});
final String updateUrl;
final VoidCallback onComplete;
@override
State<_WindowsUpdateDialog> createState() => _WindowsUpdateDialogState();
}
class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
final ValueNotifier<String> messageNotifier = ValueNotifier<String>(
'Downloading installer...',
);
@override
void initState() {
super.initState();
_startUpdate();
}
Future<void> _startUpdate() async {
try {
// Step 1: Download
final zipPath = await _downloadWindowsInstaller(
widget.updateUrl,
onProgress: (received, total) {
if (total == -1) {
progressNotifier.value = null;
} else {
progressNotifier.value = received / total;
}
},
);
if (zipPath == null) {
_showError('Failed to download installer');
return;
}
// Step 2: Extract
messageNotifier.value = 'Extracting installer...';
progressNotifier.value = null; // Indeterminate for extraction
final extractDir = await _extractWindowsInstaller(zipPath);
if (extractDir == null) {
_showError('Failed to extract installer');
return;
}
// Step 3: Run installer
messageNotifier.value = 'Running installer...';
final success = await _runWindowsInstaller(extractDir);
if (!mounted) return;
if (success) {
messageNotifier.value = 'Update Complete';
progressNotifier.value = 1.0;
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
Navigator.of(context).pop();
widget.onComplete();
}
} else {
_showError('Failed to run installer');
}
// Cleanup
try {
await File(zipPath).delete();
await Directory(extractDir).delete(recursive: true);
} catch (e) {
talker.error('[Update] Error cleaning up temporary files: $e');
}
} catch (e) {
_showError('Update failed: $e');
}
}
void _showError(String message) {
if (!mounted) return;
Navigator.of(context).pop();
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Update Failed'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Installing Update'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder<double?>(
valueListenable: progressNotifier,
builder: (context, progress, child) {
return LinearProgressIndicator(value: progress);
},
),
const SizedBox(height: 16),
ValueListenableBuilder<String>(
valueListenable: messageNotifier,
builder: (context, message, child) {
return Text(message);
},
),
],
),
);
}
/// Downloads the Windows installer ZIP file
Future<String?> _downloadWindowsInstaller(
String url, {
void Function(int received, int total)? onProgress,
}) async {
try {
talker.info('[Update] Starting Windows installer download from: $url');
final tempDir = await getTemporaryDirectory();
final fileName =
'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip';
final filePath = path.join(tempDir.path, fileName);
final response = await Dio().download(
url,
filePath,
onReceiveProgress: (received, total) {
if (total != -1) {
talker.info(
'[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%',
);
}
onProgress?.call(received, total);
},
);
if (response.statusCode == 200) {
talker.info(
'[Update] Windows installer downloaded successfully to: $filePath',
);
return filePath;
} else {
talker.error(
'[Update] Failed to download Windows installer. Status: ${response.statusCode}',
);
return null;
}
} catch (e) {
talker.error('[Update] Error downloading Windows installer: $e');
return null;
}
}
/// Extracts the ZIP file to a temporary directory
Future<String?> _extractWindowsInstaller(String zipPath) async {
try {
talker.info('[Update] Extracting Windows installer from: $zipPath');
final tempDir = await getTemporaryDirectory();
final extractDir = path.join(
tempDir.path,
'solian-installer-${DateTime.now().millisecondsSinceEpoch}',
);
final zipFile = File(zipPath);
final bytes = await zipFile.readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
final filename = file.name;
if (file.isFile) {
final data = file.content as List<int>;
final filePath = path.join(extractDir, filename);
await Directory(path.dirname(filePath)).create(recursive: true);
await File(filePath).writeAsBytes(data);
} else {
final dirPath = path.join(extractDir, filename);
await Directory(dirPath).create(recursive: true);
}
}
talker.info(
'[Update] Windows installer extracted successfully to: $extractDir',
);
return extractDir;
} catch (e) {
talker.error('[Update] Error extracting Windows installer: $e');
return null;
}
}
/// Runs the setup.exe file
Future<bool> _runWindowsInstaller(String extractDir) async {
try {
talker.info('[Update] Running Windows installer from: $extractDir');
final dir = Directory(extractDir);
final exeFiles =
dir
.listSync()
.where((f) => f is File && f.path.endsWith('.exe'))
.toList();
if (exeFiles.isEmpty) {
talker.info('[Update] No .exe file found in extracted directory');
return false;
}
final setupExePath = exeFiles.first.path;
talker.info('[Update] Found installer executable: $setupExePath');
final shell = Shell();
final results = await shell.run(setupExePath);
final result = results.first;
if (result.exitCode == 0) {
talker.info('[Update] Windows installer completed successfully');
return true;
} else {
talker.error(
'[Update] Windows installer failed with exit code: ${result.exitCode}',
);
talker.error('[Update] Installer output: ${result.stdout}');
talker.error('[Update] Installer errors: ${result.stderr}');
return false;
}
} catch (e) {
talker.error('[Update] Error running Windows installer: $e');
return false;
}
}
}
class _UpdateSheet extends StatefulWidget {
const _UpdateSheet({
required this.release,
required this.onOpen,
this.androidUpdateUrl,
this.windowsUpdateUrl,
this.useProxy = false,
});
final String? androidUpdateUrl;
final String? windowsUpdateUrl;
final bool useProxy;
final GithubReleaseInfo release;
final VoidCallback onOpen;
@override
State<_UpdateSheet> createState() => _UpdateSheetState();
}
class _UpdateSheetState extends State<_UpdateSheet> {
late bool _useProxy;
@override
void initState() {
super.initState();
_useProxy = widget.useProxy;
}
Future<void> _installUpdate(String url) async {
String downloadUrl = url;
if (_useProxy) {
final fileName = url.split('/').last;
downloadUrl = 'https://fs.solsynth.dev/d/rainyun02/solian/$fileName';
}
UpdateModel model = UpdateModel(
downloadUrl,
"solian-update-${widget.release.tagName}.apk",
"launcher_icon",
'https://apps.apple.com/us/app/solian/id6499032345',
);
AzhonAppUpdate.update(model);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SheetScaffold(
titleText: 'updateAvailable'.tr(),
child: Padding(
padding: EdgeInsets.only(
bottom: 16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.release.name,
style: theme.textTheme.titleMedium,
).bold(),
Text(widget.release.tagName).fontSize(12),
],
).padding(vertical: 16, horizontal: 16),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: MarkdownTextContent(
content:
widget.release.body.isEmpty
? 'noChangelogProvided'.tr()
: widget.release.body,
),
),
),
if (!kIsWeb && Platform.isAndroid)
SwitchListTile(
title: Text('useSecondarySourceForDownload'.tr()),
value: _useProxy,
onChanged: (value) {
setState(() {
_useProxy = value;
});
},
).padding(horizontal: 8),
Column(
children: [
Row(
spacing: 8,
children: [
if (!kIsWeb &&
Platform.isAndroid &&
widget.androidUpdateUrl != null)
Expanded(
child: FilledButton.icon(
onPressed: () {
talker.info(widget.androidUpdateUrl!);
_installUpdate(widget.androidUpdateUrl!);
},
icon: const Icon(Symbols.update),
label: Text('installUpdate'.tr()),
),
),
if (!kIsWeb &&
Platform.isWindows &&
widget.windowsUpdateUrl != null)
Expanded(
child: FilledButton.icon(
onPressed: () {
// Access the UpdateService instance to call the automatic update method
final updateService = UpdateService(
useProxy: widget.useProxy,
);
updateService.performAutomaticWindowsUpdate(
context,
widget.windowsUpdateUrl!,
);
},
icon: const Icon(Symbols.update),
label: Text('installUpdate'.tr()),
),
),
Expanded(
child: FilledButton.icon(
onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new),
label: Text('openReleasePage'.tr()),
),
),
],
),
],
).padding(horizontal: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class WidgetSyncService {
static const _channel = MethodChannel('dev.solsynth.solian/widget');
static final _instance = WidgetSyncService._internal();
factory WidgetSyncService() => _instance;
WidgetSyncService._internal();
bool get _isSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
Future<void> syncToWidget() async {
if (!_isSupported) return;
try {
await _channel.invokeMethod('syncToWidget');
} catch (e) {
debugPrint('Failed to sync to widget: $e');
}
}
}