🎨 Use feature based folder structure
This commit is contained in:
90
lib/core/services/activitypub_service.dart
Normal file
90
lib/core/services/activitypub_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
536
lib/core/services/analytics_service.dart
Normal file
536
lib/core/services/analytics_service.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
7
lib/core/services/color.dart
Normal file
7
lib/core/services/color.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
50
lib/core/services/color_extraction.dart
Normal file
50
lib/core/services/color_extraction.dart
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
53
lib/core/services/event_bus.dart
Normal file
53
lib/core/services/event_bus.dart
Normal 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 [],
|
||||
});
|
||||
}
|
||||
36
lib/core/services/image.dart
Normal file
36
lib/core/services/image.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
60
lib/core/services/notify.dart
Normal file
60
lib/core/services/notify.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
197
lib/core/services/notify.universal.dart
Normal file
197
lib/core/services/notify.universal.dart
Normal 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},
|
||||
);
|
||||
}
|
||||
179
lib/core/services/notify.windows.dart
Normal file
179
lib/core/services/notify.windows.dart
Normal 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},
|
||||
);
|
||||
}
|
||||
89
lib/core/services/quick_actions.dart
Normal file
89
lib/core/services/quick_actions.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
17
lib/core/services/responsive.dart
Normal file
17
lib/core/services/responsive.dart
Normal 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;
|
||||
}
|
||||
123
lib/core/services/sharing_intent.dart
Normal file
123
lib/core/services/sharing_intent.dart
Normal 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
103
lib/core/services/time.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
1
lib/core/services/timezone.dart
Normal file
1
lib/core/services/timezone.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'timezone/native.dart' if (dart.library.html) 'timezone/web.dart';
|
||||
22
lib/core/services/timezone/native.dart
Normal file
22
lib/core/services/timezone/native.dart
Normal 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();
|
||||
}
|
||||
21
lib/core/services/timezone/web.dart
Normal file
21
lib/core/services/timezone/web.dart
Normal 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();
|
||||
}
|
||||
67
lib/core/services/tour.dart
Normal file
67
lib/core/services/tour.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
268
lib/core/services/tour.freezed.dart
Normal file
268
lib/core/services/tour.freezed.dart
Normal 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
|
||||
63
lib/core/services/tour.g.dart
Normal file
63
lib/core/services/tour.g.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
3
lib/core/services/udid.dart
Normal file
3
lib/core/services/udid.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
export 'udid.native.dart'
|
||||
if (dart.library.html) 'udid.web.dart'
|
||||
if (dart.library.io) 'udid.native.dart';
|
||||
29
lib/core/services/udid.native.dart
Normal file
29
lib/core/services/udid.native.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
26
lib/core/services/udid.web.dart
Normal file
26
lib/core/services/udid.web.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
725
lib/core/services/update_service.dart
Normal file
725
lib/core/services/update_service.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/core/services/widget_sync_service.dart
Normal file
24
lib/core/services/widget_sync_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user