From 8a3ff70523dc10acf44a6c26436b657d89c9a56d Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 30 Apr 2023 13:39:09 +0200 Subject: [PATCH] Add user avatars --- lib/api_client.dart | 68 ++++++++----- lib/apis/friend_api.dart | 6 +- lib/apis/message_api.dart | 6 +- lib/main.dart | 76 ++++++++------ lib/models/friend.dart | 12 ++- lib/models/message.dart | 43 ++++++-- lib/models/user_profile.dart | 20 ++++ lib/widgets/home_screen.dart | 34 ++++++- lib/widgets/messages.dart | 45 +++++++-- pubspec.lock | 188 ++++++++++++++++++++++++++++++++++- pubspec.yaml | 2 + 11 files changed, 415 insertions(+), 85 deletions(-) create mode 100644 lib/models/user_profile.dart diff --git a/lib/api_client.dart b/lib/api_client.dart index c7227ab..a1b9a00 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:developer'; +import 'package:contacts_plus/models/message.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; @@ -7,7 +8,11 @@ import 'package:contacts_plus/models/authentication_data.dart'; import 'package:signalr_netcore/http_connection_options.dart'; import 'package:signalr_netcore/hub_connection.dart'; import 'package:signalr_netcore/hub_connection_builder.dart'; +import 'package:signalr_netcore/ihub_protocol.dart'; +import 'package:signalr_netcore/msgpack_hub_protocol.dart'; +import 'package:signalr_netcore/web_supporting_http_client.dart'; import 'package:uuid/uuid.dart'; +import 'package:logging/logging.dart'; import 'config.dart'; @@ -17,21 +22,17 @@ class ApiClient { static const String tokenKey = "token"; static const String passwordKey = "password"; - static final ApiClient _singleton = ApiClient._internal(); - - factory ApiClient() { - return _singleton; + ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData { + if (_authenticationData.isAuthenticated) { + hub.start(); + } } - ApiClient._internal(); + late final NeosHub hub = NeosHub(token: authorizationHeader.values.first); + final AuthenticationData _authenticationData; - final NeosHub _hub = NeosHub(); - AuthenticationData? _authenticationData; - - set authenticationData(value) => _authenticationData = value; - - String get userId => _authenticationData!.userId; - bool get isAuthenticated => _authenticationData?.isAuthenticated ?? false; + String get userId => _authenticationData.userId; + bool get isAuthenticated => _authenticationData.isAuthenticated; static Future tryLogin({ required String username, @@ -104,7 +105,7 @@ class ApiClient { } Map get authorizationHeader => { - "Authorization": "neos ${_authenticationData!.userId}:${_authenticationData!.token}" + "Authorization": "neos ${_authenticationData.userId}:${_authenticationData.token}" }; static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path"); @@ -136,19 +137,38 @@ class ApiClient { } class NeosHub { - final HubConnection hubConnection; - late final Future? _hubConnectedFuture; + late final HubConnection hubConnection; + final Logger _logger = Logger("NeosHub"); - NeosHub() : hubConnection = HubConnectionBuilder() - .withUrl(Config.neosHubUrl, options: HttpConnectionOptions()) - .withAutomaticReconnect() - .build() { - _hubConnectedFuture = hubConnection.start()?.whenComplete(() { + NeosHub({required String token}) { + hubConnection = HubConnectionBuilder() + .withUrl( + Config.neosHubUrl, + options: HttpConnectionOptions( + headers: MessageHeaders() + ..setHeaderValue("Authorization", token), + httpClient: WebSupportingHttpClient( + _logger, + ), + logger: _logger, + logMessageContent: true + ), + ).withAutomaticReconnect().build(); + hubConnection.onreconnecting(({error}) { + log("onreconnecting called with error $error"); + }); + hubConnection.onreconnected(({connectionId}) { + log("onreconnected called"); + }); + } + + void start() { + hubConnection.start()?.onError((error, stackTrace) => log(error.toString())).whenComplete(() { log("Hub connection established"); }); } -} -class BaseClient { - static final client = ApiClient(); -} \ No newline at end of file + Future sendMessage(Message message) async { + await hubConnection.send("SendMessage", args: [message.toMap()]); + } +} diff --git a/lib/apis/friend_api.dart b/lib/apis/friend_api.dart index eca53b0..3e920ba 100644 --- a/lib/apis/friend_api.dart +++ b/lib/apis/friend_api.dart @@ -4,9 +4,9 @@ import 'dart:convert'; import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/models/friend.dart'; -class FriendApi extends BaseClient { - static Future> getFriendsList() async { - final response = await BaseClient.client.get("/users/${BaseClient.client.userId}/friends"); +class FriendApi { + static Future> getFriendsList(ApiClient client) async { + final response = await client.get("/users/${client.userId}/friends"); ApiClient.checkResponse(response); final data = jsonDecode(response.body) as List; return data.map((e) => Friend.fromMap(e)); diff --git a/lib/apis/message_api.dart b/lib/apis/message_api.dart index 762309b..20eda0c 100644 --- a/lib/apis/message_api.dart +++ b/lib/apis/message_api.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/models/message.dart'; -class MessageApi extends BaseClient { - static Future> getUserMessages({String userId="", DateTime? fromTime, int maxItems=50, bool unreadOnly=false}) async { - final response = await BaseClient.client.get("/users/${BaseClient.client.userId}/messages" +class MessageApi { + static Future> getUserMessages(ApiClient client, {String userId="", DateTime? fromTime, int maxItems=50, bool unreadOnly=false}) async { + final response = await client.get("/users/${client.userId}/messages" "?maxItems=$maxItems" "${fromTime == null ? "" : "&fromTime${fromTime.toLocal().toIso8601String()}"}" "${userId.isEmpty ? "" : "&user=$userId"}" diff --git a/lib/main.dart b/lib/main.dart index b1217d9..edb0f8c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,50 +5,62 @@ import 'api_client.dart'; import 'models/authentication_data.dart'; void main() { - runApp(ContactsPlus()); + runApp(const ContactsPlus()); } -class ContactsPlus extends StatelessWidget { - ContactsPlus({super.key}); +class ContactsPlus extends StatefulWidget { + const ContactsPlus({super.key}); + + @override + State createState() => _ContactsPlusState(); +} + +class _ContactsPlusState extends State { final Typography _typography = Typography.material2021(platform: TargetPlatform.android); + AuthenticationData _authData = AuthenticationData.unauthenticated(); @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Contacts+', - theme: ThemeData( - textTheme: _typography.white, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) + return ClientHolder( + authenticationData: _authData, + child: MaterialApp( + title: 'Contacts+', + theme: ThemeData( + textTheme: _typography.white, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) + ), + home: _authData.isAuthenticated ? + const HomeScreen() : + LoginScreen( + onLoginSuccessful: (AuthenticationData authData) { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, + ), ), - home: const SplashScreen(), ); } } -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); +class ClientHolder extends InheritedWidget { + final ApiClient client; - @override - State createState() => _SplashScreenState(); -} + ClientHolder({super.key, required AuthenticationData authenticationData, required super.child}) + : client = ApiClient(authenticationData: authenticationData); -class _SplashScreenState extends State { - final ApiClient _apiClient = ApiClient(); - - @override - Widget build(BuildContext context) { - if (_apiClient.isAuthenticated) { - return const HomeScreen(); - } else { - return LoginScreen( - onLoginSuccessful: (AuthenticationData authData) { - if (authData.isAuthenticated) { - setState(() { - _apiClient.authenticationData = authData; - }); - } - }, - ); - } + static ClientHolder? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); } + + static ClientHolder of(BuildContext context) { + final ClientHolder? result = maybeOf(context); + assert(result != null, 'No AuthenticatedClient found in context'); + return result!; + } + + @override + bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client; } \ No newline at end of file diff --git a/lib/models/friend.dart b/lib/models/friend.dart index 6ec49b1..f983cb9 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -1,16 +1,22 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; +import 'package:contacts_plus/models/user_profile.dart'; class Friend extends Comparable { final String id; final String username; final UserStatus userStatus; + final UserProfile userProfile; - Friend({required this.id, required this.username, required this.userStatus}); + Friend({required this.id, required this.username, required this.userStatus, required this.userProfile}); factory Friend.fromMap(Map map) { - return Friend(id: map["id"], username: map["friendUsername"], userStatus: UserStatus.fromMap(map["userStatus"])); + return Friend( + id: map["id"], + username: map["friendUsername"], + userStatus: UserStatus.fromMap(map["userStatus"]), + userProfile: UserProfile.fromMap(map["profile"] ?? {"iconUrl": ""}), + ); } @override diff --git a/lib/models/message.dart b/lib/models/message.dart index fc2cf72..0c106bf 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,11 +1,30 @@ import 'dart:developer'; +import 'package:uuid/uuid.dart'; + enum MessageType { unknown, text, sound, sessionInvite, - object, + object; + + static const Map _mapper = { + MessageType.text: "Text", + MessageType.sound: "Sound", + MessageType.sessionInvite: "SessionInvite", + MessageType.object: "Object", + }; + + factory MessageType.fromName(String name) { + return MessageType.values.firstWhere((element) => element.name.toLowerCase() == name.toLowerCase(), + orElse: () => MessageType.unknown, + ); + } + + String? toName() { + return _mapper[this]; + } } class Message { @@ -20,11 +39,9 @@ class Message { required this.content, required this.sendTime}); factory Message.fromMap(Map map) { - final typeString = map["messageType"] as String?; - final type = MessageType.values.firstWhere((element) => element.name.toLowerCase() == typeString?.toLowerCase(), - orElse: () => MessageType.unknown, - ); - if (type == MessageType.unknown && typeString != null) { + final typeString = (map["messageType"] as String?) ?? ""; + final type = MessageType.fromName(typeString); + if (type == MessageType.unknown && typeString.isNotEmpty) { log("Unknown MessageType '$typeString' in response"); } return Message( @@ -36,4 +53,18 @@ class Message { sendTime: DateTime.parse(map["sendTime"]), ); } + + Map toMap() => { + "id": id, + "recipientId": recipientId, + "senderId": senderId, + "ownerId": senderId, + "messageType": type.toName(), + "content": content, + "sendTime": sendTime.toIso8601String(), + }; + + static String generateId() { + return "MSG-${const Uuid().v4()}"; + } } \ No newline at end of file diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart new file mode 100644 index 0000000..d307dcd --- /dev/null +++ b/lib/models/user_profile.dart @@ -0,0 +1,20 @@ +import 'package:contacts_plus/config.dart'; + +class UserProfile { + final String iconUrl; + + UserProfile({required this.iconUrl}); + + factory UserProfile.fromMap(Map map) { + return UserProfile(iconUrl: map["iconUrl"]); + } + + Uri get httpIconUri { + final fullUri = iconUrl.replaceFirst("neosdb:///", Config.neosCdnUrl); + final lastPeriodIndex = fullUri.lastIndexOf("."); + if (lastPeriodIndex != -1 && fullUri.length - lastPeriodIndex < 8) { + return Uri.parse(fullUri.substring(0, lastPeriodIndex)); + } + return Uri.parse(fullUri); + } +} \ No newline at end of file diff --git a/lib/widgets/home_screen.dart b/lib/widgets/home_screen.dart index 24dec17..f8f6a43 100644 --- a/lib/widgets/home_screen.dart +++ b/lib/widgets/home_screen.dart @@ -1,4 +1,6 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus/apis/friend_api.dart'; +import 'package:contacts_plus/aux.dart'; import 'package:contacts_plus/main.dart'; import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/widgets/messages.dart'; @@ -13,15 +15,20 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { Future>? _friendsFuture; + ClientHolder? _clientHolder; @override - void initState() { - super.initState(); - _refreshFriendsList(); + void didChangeDependencies() { + super.didChangeDependencies(); + final clientHolder = ClientHolder.of(context); + if (_clientHolder != clientHolder) { + _clientHolder = clientHolder; + _refreshFriendsList(); + } } void _refreshFriendsList() { - _friendsFuture = FriendApi.getFriendsList().then((Iterable value) => + _friendsFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable value) => value.toList() ..sort((a, b) { if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) { @@ -40,6 +47,7 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { + final apiClient = ClientHolder.of(context).client; return Scaffold( appBar: AppBar( title: const Text("Contacts+"), @@ -58,11 +66,27 @@ class _HomeScreenState extends State { itemCount: data.length, itemBuilder: (context, index) { final entry = data.elementAt(index); + final iconUri = entry.userProfile.httpIconUri.toString(); return ListTile( + leading: CachedNetworkImage( + imageBuilder: (context, imageProvider) { + return CircleAvatar( + foregroundImage: imageProvider, + ); + }, + imageUrl: iconUri, + placeholder: (context, url) { + return const CircleAvatar(backgroundColor: Colors.white54,); + }, + errorWidget: (context, error, what) => const CircleAvatar( + backgroundColor: Colors.transparent, + child: Icon(Icons.person), + ), + ), title: Text(entry.username), subtitle: Text(entry.userStatus.onlineStatus.name), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => Messages(friend: entry))); + Navigator.of(context).push(MaterialPageRoute(builder: (context) => Messages(friend: entry))); }, ); }, diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart index e11073e..72f1623 100644 --- a/lib/widgets/messages.dart +++ b/lib/widgets/messages.dart @@ -1,5 +1,5 @@ -import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/apis/message_api.dart'; +import 'package:contacts_plus/main.dart'; import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/message.dart'; import 'package:flutter/material.dart'; @@ -18,22 +18,27 @@ class Messages extends StatefulWidget { class _MessagesState extends State { Future>? _messagesFuture; final TextEditingController _messageTextController = TextEditingController(); + ClientHolder? _clientHolder; bool _isSendable = false; void _refreshMessages() { - _messagesFuture = MessageApi.getUserMessages(userId: widget.friend.id)..then((value) => value.toList()); + _messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id)..then((value) => value.toList()); } @override - void initState() { - super.initState(); - _refreshMessages(); + void didChangeDependencies() { + super.didChangeDependencies(); + final clientHolder = ClientHolder.of(context); + if (_clientHolder != clientHolder) { + _clientHolder = clientHolder; + _refreshMessages(); + } } @override Widget build(BuildContext context) { - final apiClient = ApiClient(); + final apiClient = ClientHolder.of(context).client; return Scaffold( appBar: AppBar( title: Text(widget.friend.username), @@ -110,7 +115,33 @@ class _MessagesState extends State { padding: const EdgeInsets.only(right: 8.0), child: IconButton( splashRadius: 24, - onPressed: _isSendable ? () {} : null, + onPressed: _isSendable ? () async { + setState(() { + _isSendable = false; + }); + final message = Message( + id: Message.generateId(), + recipientId: widget.friend.id, + senderId: apiClient.userId, type: MessageType.text, + content: _messageTextController.text, + sendTime: DateTime.now().toUtc(), + ); + try { + await apiClient.hub.sendMessage(message); + _messageTextController.clear(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Failed to send message\n$e", + maxLines: null, + ), + ), + ); + setState(() { + _isSendable = true; + }); + } + } : null, iconSize: 28, icon: const Icon(Icons.send), ), diff --git a/pubspec.lock b/pubspec.lock index 5c35d50..9ec4c0a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15 + url: "https://pub.dev" + source: hosted + version: "3.2.3" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0 + url: "https://pub.dev" + source: hosted + version: "1.0.2" characters: dependency: transitive description: @@ -65,11 +89,43 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: a38574032c5f1dd06c4aee541789906c12ccaab8ba01446e800d9c5b79c4a978 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_blurhash: + dependency: transitive + description: + name: flutter_blurhash + sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "32cd900555219333326a2d0653aaaf8671264c29befa65bbd9856d204a4c9fb3" + url: "https://pub.dev" + source: hosted + version: "3.3.0" flutter_lints: dependency: "direct dev" description: @@ -177,7 +233,7 @@ packages: source: hosted version: "2.0.1" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" @@ -216,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143" + url: "https://pub.dev" + source: hosted + version: "1.0.2" path: dependency: "direct main" description: @@ -224,6 +288,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.2" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4 + url: "https://pub.dev" + source: hosted + version: "2.0.14" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + url: "https://pub.dev" + source: hosted + version: "2.0.27" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: ad4c4d011830462633f03eb34445a45345673dfd4faf1ab0b4735fbd93b19183 + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "2ae08f2216225427e64ad224a24354221c2c7907e448e6e0e8b57b1eb9f10ad1" + url: "https://pub.dev" + source: hosted + version: "2.1.10" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + pedantic: + dependency: transitive + description: + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + platform: + dependency: transitive + description: + name: platform + sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" plugin_platform_interface: dependency: transitive description: @@ -232,6 +360,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + process: + dependency: transitive + description: + name: process + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + url: "https://pub.dev" + source: hosted + version: "4.2.4" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" signalr_netcore: dependency: "direct main" description: @@ -253,6 +397,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "8453780d1f703ead201a39673deb93decf85d543f359f750e2afc4908b55533f" + url: "https://pub.dev" + source: hosted + version: "2.2.8" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: e77abf6ff961d69dfef41daccbb66b51e9983cdd5cb35bf30733598057401555 + url: "https://pub.dev" + source: hosted + version: "2.4.5" sse_client: dependency: transitive description: @@ -285,6 +445,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" + source: hosted + version: "3.1.0" term_glyph: dependency: transitive description: @@ -341,6 +509,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + win32: + dependency: transitive + description: + name: win32 + sha256: dd8f9344bc305ae2923e3d11a2a911d9a4e2c7dd6fe0ed10626d63211a69676e + url: "https://pub.dev" + source: hosted + version: "4.1.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 + url: "https://pub.dev" + source: hosted + version: "1.0.0" sdks: dart: ">=2.19.6 <3.0.0" - flutter: ">=2.0.0" + flutter: ">=3.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index d42074b..5d2e90f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: intl: ^0.18.1 path: ^1.8.2 signalr_netcore: ^1.3.3 + logging: ^1.1.1 + cached_network_image: ^3.2.3 dev_dependencies: flutter_test: