diff --git a/i18n/en.json b/i18n/en.json index d813b7f335..686844e59f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1287,6 +1287,7 @@ "no_archived_assets_message": "Archive photos and videos to hide them from your Photos view", "no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO", "no_assets_to_show": "No assets to show", + "no_cast_devices_found": "No cast devices found", "no_duplicates_found": "No duplicates were found.", "no_exif_info_available": "No exif info available", "no_explore_results_message": "Upload more photos to explore your collection.", diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 537cdba8d8..c5880221a3 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,6 +1,9 @@ PODS: - background_downloader (0.0.1): - Flutter + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - connectivity_plus (0.0.1): - Flutter - device_info_plus (0.0.1): @@ -129,6 +132,7 @@ PODS: DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) + - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -173,6 +177,8 @@ SPEC REPOS: EXTERNAL SOURCES: background_downloader: :path: ".symlinks/plugins/background_downloader/ios" + bonsoir_darwin: + :path: ".symlinks/plugins/bonsoir_darwin/darwin" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -235,44 +241,45 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd - device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + background_downloader: a05c77d32a0d70615b9c04577aa203535fc924ff + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 - flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 - fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 - geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 - local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 + flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 + flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab + flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603 + fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f + geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097 + local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e - maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9 - native_video_player: b65c58951ede2f93d103a25366bdebca95081265 - network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 + maplibre_gl: be7b98f1c3ed75bf77f321eec04df359d0ff6f62 + native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c + network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 - share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb + share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 - sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 + sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56 PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index e0c719fd0f..91d6d50908 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -113,6 +113,11 @@ NSAllowsArbitraryLoads + NSBonjourServices + + _googlecast._tcp + _CC1AD845._googlecast._tcp + NSCameraUsageDescription We need to access the camera to let you take beautiful video using this app NSLocationAlwaysAndWhenInUseUsageDescription @@ -164,4 +169,4 @@ NSFaceIDUsageDescription We need to use FaceID to allow access to your locked folder - \ No newline at end of file + diff --git a/mobile/lib/interfaces/cast_destination_service.interface.dart b/mobile/lib/interfaces/cast_destination_service.interface.dart new file mode 100644 index 0000000000..4481eb597e --- /dev/null +++ b/mobile/lib/interfaces/cast_destination_service.interface.dart @@ -0,0 +1,25 @@ +import 'package:immich_mobile/models/cast_manager_state.dart'; + +abstract interface class ICastDestinationService { + Future initialize(); + CastDestinationType getType(); + + bool isAvailable(); + + void Function(bool)? onConnectionState; + + void Function(Duration)? onCurrentTime; + void Function(Duration)? onDuration; + + void Function(String)? onReceiverName; + void Function(CastState)? onCastState; + + void loadMedia(String url, String sessionKey, bool reload); + + void play(); + void pause(); + void seekTo(Duration position); + void disconnect(); + + Future> getDevices(); +} diff --git a/mobile/lib/models/cast_manager_state.dart b/mobile/lib/models/cast_manager_state.dart new file mode 100644 index 0000000000..e9bb0c5efe --- /dev/null +++ b/mobile/lib/models/cast_manager_state.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +enum CastDestinationType { googleCast } + +enum CastState { idle, playing, paused, buffering } + +class CastManagerState { + final bool isCasting; + final String receiverName; + final CastState castState; + final Duration currentTime; + final Duration duration; + + CastManagerState({ + required this.isCasting, + required this.receiverName, + required this.castState, + required this.currentTime, + required this.duration, + }); + + CastManagerState copyWith({ + bool? isCasting, + String? receiverName, + CastState? castState, + Duration? currentTime, + Duration? duration, + }) { + return CastManagerState( + isCasting: isCasting ?? this.isCasting, + receiverName: receiverName ?? this.receiverName, + castState: castState ?? this.castState, + currentTime: currentTime ?? this.currentTime, + duration: duration ?? this.duration, + ); + } + + Map toMap() { + final result = {}; + + result.addAll({'isCasting': isCasting}); + result.addAll({'receiverName': receiverName}); + result.addAll({'castState': castState}); + result.addAll({'currentTime': currentTime.inSeconds}); + result.addAll({'duration': duration.inSeconds}); + + return result; + } + + factory CastManagerState.fromMap(Map map) { + return CastManagerState( + isCasting: map['isCasting'] ?? false, + receiverName: map['receiverName'] ?? '', + castState: map['castState'] ?? CastState.idle, + currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0), + duration: Duration(seconds: map['duration']?.toInt() ?? 0)); + } + + String toJson() => json.encode(toMap()); + + factory CastManagerState.fromJson(String source) => + CastManagerState.fromMap(json.decode(source)); + + @override + String toString() => + 'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is CastManagerState && + other.isCasting == isCasting && + other.receiverName == receiverName && + other.castState == castState && + other.currentTime == currentTime && + other.duration == duration; + } + + @override + int get hashCode => + isCasting.hashCode ^ + receiverName.hashCode ^ + castState.hashCode ^ + currentTime.hashCode ^ + duration.hashCode; +} diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart new file mode 100644 index 0000000000..c71a942221 --- /dev/null +++ b/mobile/lib/providers/cast.provider.dart @@ -0,0 +1,89 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/cast_manager_state.dart'; +import 'package:immich_mobile/services/gcast.service.dart'; + +final castProvider = StateNotifierProvider( + (ref) => CastNotifier(ref.watch(gCastServiceProvider)), +); + +class CastNotifier extends StateNotifier { + final GCastService _gCastService; + + CastNotifier(this._gCastService) + : super( + CastManagerState( + isCasting: false, + currentTime: Duration.zero, + duration: Duration.zero, + receiverName: '', + castState: CastState.idle, + ), + ) { + _gCastService.onConnectionState = _onConnectionState; + _gCastService.onCurrentTime = _onCurrentTime; + _gCastService.onDuration = _onDuration; + _gCastService.onReceiverName = _onReceiverName; + _gCastService.onCastState = _onCastState; + } + + void _onConnectionState(bool isCasting) { + state = state.copyWith(isCasting: isCasting); + } + + void _onCurrentTime(Duration currentTime) { + state = state.copyWith(currentTime: currentTime); + } + + void _onDuration(Duration duration) { + state = state.copyWith(duration: duration); + } + + void _onReceiverName(String receiverName) { + state = state.copyWith(receiverName: receiverName); + } + + void _onCastState(CastState castState) { + state = state.copyWith(castState: castState); + } + + void loadMedia(String url, String sessionKey, bool reload) { + _gCastService.loadMedia(url, sessionKey, reload); + } + + Future connect(CastDestinationType type, dynamic device) async { + switch (type) { + case CastDestinationType.googleCast: + await _gCastService.connect(device); + break; + } + } + + Future> getDevices() async { + // return _gCastService.getDevices(); + // delay for 2 seconds to simulate loading + await Future.delayed(const Duration(seconds: 2)); + + return Future>.value([ + ('Google Cast', CastDestinationType.googleCast, null), + ('Apple TV', CastDestinationType.googleCast, null), + ('Roku', CastDestinationType.googleCast, null), + ('Fire TV', CastDestinationType.googleCast, null), + ]); + } + + void play() { + _gCastService.play(); + } + + void pause() { + _gCastService.pause(); + } + + void seekTo(Duration position) { + _gCastService.seekTo(position); + } + + void disconnect() { + _gCastService.disconnect(); + } +} diff --git a/mobile/lib/repositories/gcast.repository.dart b/mobile/lib/repositories/gcast.repository.dart new file mode 100644 index 0000000000..bd677c73aa --- /dev/null +++ b/mobile/lib/repositories/gcast.repository.dart @@ -0,0 +1,56 @@ +import 'package:cast/device.dart'; +import 'package:cast/session.dart'; +import 'package:cast/session_manager.dart'; +import 'package:cast/discovery_service.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final gCastRepositoryProvider = Provider((_) { + return GCastRepository(); +}); + +class GCastRepository { + CastSession? _castSession; + + void Function(CastSessionState)? onCastStatus; + void Function(Map)? onCastMessage; + + GCastRepository(); + + Future connect(CastDevice device) async { + print("Connecting to ${device.name}"); + _castSession = await CastSessionManager().startSession(device); + + _castSession?.stateStream.listen((state) { + onCastStatus?.call(state); + }); + + _castSession?.messageStream.listen((message) { + onCastMessage?.call(message); + }); + + // open the default receiver + sendMessage({ + 'type': 'LAUNCH', + 'appId': 'CC1AD845', + }); + + print("Connected to ${device.name}"); + } + + Future disconnect() async { + await _castSession?.close(); + } + + void sendMessage(Map message) { + if (_castSession == null) { + throw Exception("Cast session is not established"); + } + + _castSession!.sendMessage(CastSession.kNamespaceReceiver, message); + } + + Future> listDestinations() async { + return await CastDiscoveryService() + .search(timeout: const Duration(seconds: 3)); + } +} diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart new file mode 100644 index 0000000000..6db1ce0e42 --- /dev/null +++ b/mobile/lib/services/gcast.service.dart @@ -0,0 +1,109 @@ +import 'package:cast/device.dart'; +import 'package:cast/session.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart'; +import 'package:immich_mobile/models/cast_manager_state.dart'; +import 'package:immich_mobile/repositories/gcast.repository.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +final gCastServiceProvider = + Provider((ref) => GCastService(ref.watch(gCastRepositoryProvider))); + +class GCastService implements ICastDestinationService { + final GCastRepository _gCastRepository; + + @override + void Function(bool)? onConnectionState; + @override + void Function(Duration)? onCurrentTime; + @override + void Function(Duration)? onDuration; + @override + void Function(String)? onReceiverName; + @override + void Function(CastState)? onCastState; + + GCastService(this._gCastRepository) { + _gCastRepository.onCastStatus = _onCastStatusCallback; + _gCastRepository.onCastMessage = _onCastMessageCallback; + } + + void _onCastStatusCallback(CastSessionState state) { + if (state == CastSessionState.connected) { + onConnectionState?.call(true); + } else if (state == CastSessionState.closed) { + onConnectionState?.call(false); + } + } + + void _onCastMessageCallback(Map message) { + final msgType = message['type']; + + print(message); + + if (msgType == 'RECEIVER_STATUS') { + print("Got receiver status"); + } + } + + Future connect(CastDevice device) async { + await _gCastRepository.connect(device); + } + + @override + CastDestinationType getType() { + return CastDestinationType.googleCast; + } + + @override + Future initialize() async { + // check if server URL is https + final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint)); + + return serverUrl?.startsWith("https://") ?? false; + } + + @override + void disconnect() { + _gCastRepository.disconnect(); + } + + @override + bool isAvailable() { + // check if server URL is https + final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint)); + + return serverUrl?.startsWith("https://") ?? false; + } + + @override + void loadMedia(String url, String sessionKey, bool reload) { + // TODO: implement loadMedia + } + + @override + void play() { + // TODO: implement play + } + + @override + void pause() { + // TODO: implement pause + } + + @override + void seekTo(Duration position) { + // TODO: implement seekTo + } + + @override + Future> getDevices() async { + final dests = await _gCastRepository.listDestinations(); + + return dests + .map((device) => (device.name, CastDestinationType.googleCast, device)) + .toList(growable: false); + } +} diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart new file mode 100644 index 0000000000..12505c7e15 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -0,0 +1,82 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/cast_manager_state.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; + +class CastDialog extends ConsumerWidget { + const CastDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AlertDialog( + title: const Text( + "cast", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + content: SizedBox( + width: 250, + height: 250, + child: FutureBuilder>( + future: ref.read(castProvider.notifier).getDevices(), + builder: (context, snapshot) { + final cast = ref.read(castProvider.notifier); + + if (snapshot.hasError) { + return Text( + 'Error: ${snapshot.error.toString()}', + ); + } else if (!snapshot.hasData) { + return const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.data!.isEmpty) { + return const Text( + 'no_cast_devices_found', + ).tr(); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final found = snapshot.data![index]; + final deviceName = found.$1; + final type = found.$2; + final deviceObj = found.$3; + + return ListTile( + title: Text(deviceName), + leading: Icon( + type == CastDestinationType.googleCast + ? Icons.cast + : Icons.cast_connected, + ), + onTap: () { + cast.connect(type, deviceObj); + }, + ); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "close", + style: TextStyle( + color: context.colorScheme.tertiary, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 64cb1c619f..2d9a1fd1ea 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; @@ -169,6 +170,20 @@ class TopControlAppBar extends HookConsumerWidget { ); } + Widget buildCastButton() { + return IconButton( + onPressed: () { + showDialog( + context: context, builder: (context) => const CastDialog()); + }, + icon: Icon( + Icons.cast, + size: 20.0, + color: Colors.grey[200], + ), + ); + } + bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home; bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed; @@ -193,6 +208,7 @@ class TopControlAppBar extends HookConsumerWidget { !asset.isTrashed && !isInLockedView) buildAddToAlbumButton(), + buildCastButton(), if (asset.isTrashed) buildRestoreButton(), if (album != null && album.shared && !isInLockedView) buildActivitiesButton(), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3df4e4e8a9..4cd928a31b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -86,6 +86,54 @@ packages: url: "https://pub.dev" source: hosted version: "9.2.0" + bonsoir: + dependency: transitive + description: + name: bonsoir + sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496" + url: "https://pub.dev" + source: hosted + version: "5.1.11" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b" + url: "https://pub.dev" + source: hosted + version: "5.1.6" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4" + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2 + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45" + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d + url: "https://pub.dev" + source: hosted + version: "5.1.5" boolean_selector: dependency: transitive description: @@ -198,6 +246,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + cast: + dependency: "direct main" + description: + name: cast + sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e + url: "https://pub.dev" + source: hosted + version: "2.1.0" characters: dependency: transitive description: @@ -1400,6 +1456,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" pub_semver: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 37c9ef7498..80947b42c6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: background_downloader: ^9.2.0 cached_network_image: ^3.4.1 cancellation_token_http: ^2.1.0 + cast: ^2.1.0 collection: ^1.18.0 connectivity_plus: ^6.1.3 crop_image: ^1.0.16