diff --git a/lib/api_client.dart b/lib/api_client.dart index 043bd73..c7227ab 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -1,8 +1,12 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; 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:uuid/uuid.dart'; import 'config.dart'; @@ -21,6 +25,7 @@ class ApiClient { ApiClient._internal(); + final NeosHub _hub = NeosHub(); AuthenticationData? _authenticationData; set authenticationData(value) => _authenticationData = value; @@ -130,6 +135,20 @@ class ApiClient { } } +class NeosHub { + final HubConnection hubConnection; + late final Future? _hubConnectedFuture; + + NeosHub() : hubConnection = HubConnectionBuilder() + .withUrl(Config.neosHubUrl, options: HttpConnectionOptions()) + .withAutomaticReconnect() + .build() { + _hubConnectedFuture = hubConnection.start()?.whenComplete(() { + log("Hub connection established"); + }); + } +} + class BaseClient { static final client = ApiClient(); } \ No newline at end of file diff --git a/lib/aux.dart b/lib/aux.dart new file mode 100644 index 0000000..d6ca95d --- /dev/null +++ b/lib/aux.dart @@ -0,0 +1,43 @@ +import 'package:contacts_plus/config.dart'; +import 'package:path/path.dart' as p; + +enum NeosDBEndpoint +{ + def, + blob, + cdn, + videoCDN, +} + +extension NeosStringExtensions on Uri { + static String dbSignature(Uri neosdb) => neosdb.pathSegments.length < 2 ? "" : p.basenameWithoutExtension(neosdb.pathSegments[1]); + static String? neosDBQuery(Uri neosdb) => neosdb.query.trim().isEmpty ? null : neosdb.query.substring(1); + static bool isLegacyNeosDB(Uri uri) => !(uri.scheme != "neosdb") && uri.pathSegments.length >= 2 && p.basenameWithoutExtension(uri.pathSegments[1]).length < 30; + + Uri neosDBToHTTP(NeosDBEndpoint endpoint) { + var signature = dbSignature(this); + var query = neosDBQuery(this); + if (query != null) { + signature = "$signature/$query"; + } + if (isLegacyNeosDB(this)) { + return Uri.parse(Config.legacyCloudUrl + signature); + } + String base; + switch (endpoint) { + case NeosDBEndpoint.blob: + base = Config.blobStorageUrl; + break; + case NeosDBEndpoint.cdn: + base = Config.neosCdnUrl; + break; + case NeosDBEndpoint.videoCDN: + base = Config.videoStorageUrl; + break; + case NeosDBEndpoint.def: + base = Config.neosAssetsUrl; + } + + return Uri.parse(base + signature); + } +} \ No newline at end of file diff --git a/lib/config.dart b/lib/config.dart index 4304d90..6606e38 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -1,3 +1,9 @@ class Config { static const String apiBaseUrl = "https://cloudx.azurewebsites.net"; + static const String legacyCloudUrl = "https://neoscloud.blob.core.windows.net/assets/"; + static const String blobStorageUrl = "https://cloudxstorage.blob.core.windows.net/assets/"; + static const String videoStorageUrl = "https://cloudx-video.azureedge.net/"; + static const String neosCdnUrl = "https://cloudx.azureedge.net/assets/"; + static const String neosAssetsUrl = "https://cloudxstorage.blob.core.windows.net/assets/"; + static const String neosHubUrl = "$apiBaseUrl/hub"; } \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index 9153028..fc2cf72 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -14,8 +14,10 @@ class Message { final String senderId; final MessageType type; final String content; + final DateTime sendTime; - Message({required this.id, required this.recipientId, required this.senderId, required this.type, required this.content}); + Message({required this.id, required this.recipientId, required this.senderId, required this.type, + required this.content, required this.sendTime}); factory Message.fromMap(Map map) { final typeString = map["messageType"] as String?; @@ -31,6 +33,7 @@ class Message { senderId: map["senderId"], type: type, content: map["content"], + sendTime: DateTime.parse(map["sendTime"]), ); } } \ No newline at end of file diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart index 522c1ef..e11073e 100644 --- a/lib/widgets/messages.dart +++ b/lib/widgets/messages.dart @@ -3,6 +3,7 @@ import 'package:contacts_plus/apis/message_api.dart'; import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/message.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; class Messages extends StatefulWidget { const Messages({required this.friend, super.key}); @@ -16,6 +17,9 @@ class Messages extends StatefulWidget { class _MessagesState extends State { Future>? _messagesFuture; + final TextEditingController _messageTextController = TextEditingController(); + + bool _isSendable = false; void _refreshMessages() { _messagesFuture = MessageApi.getUserMessages(userId: widget.friend.id)..then((value) => value.toList()); @@ -55,7 +59,9 @@ class _MessagesState extends State { Text("Failed to load messages:\n${snapshot.error}"), TextButton.icon( onPressed: () { - + setState(() { + _refreshMessages(); + }); }, icon: const Icon(Icons.refresh), label: const Text("Retry"), @@ -67,14 +73,60 @@ class _MessagesState extends State { } }, ), + bottomNavigationBar: BottomAppBar( + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: TextField( + controller: _messageTextController, + maxLines: 4, + minLines: 1, + onChanged: (text) { + if (text.isNotEmpty && !_isSendable) { + setState(() { + _isSendable = true; + }); + } else if (text.isEmpty && _isSendable) { + setState(() { + _isSendable = false; + }); + } + }, + decoration: InputDecoration( + isDense: true, + hintText: "Send a message to ${widget.friend.username}...", + hintStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: Colors.white54), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + splashRadius: 24, + onPressed: _isSendable ? () {} : null, + iconSize: 28, + icon: const Icon(Icons.send), + ), + ) + ], + ), + ), ); } } class MyMessageBubble extends StatelessWidget { - const MyMessageBubble({required this.message, super.key}); + MyMessageBubble({required this.message, super.key}); final Message message; + final DateFormat _dateFormat = DateFormat.Hm(); @override Widget build(BuildContext context) { @@ -98,12 +150,22 @@ class MyMessageBubble extends StatelessWidget { color: Theme.of(context).colorScheme.primaryContainer, margin: const EdgeInsets.only(left: 32, bottom: 16, right: 8), child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - content, - softWrap: true, - maxLines: null, - style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + content, + softWrap: true, + maxLines: null, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 6,), + Text( + _dateFormat.format(message.sendTime), + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), + ), + ], ), ), ), @@ -115,9 +177,10 @@ class MyMessageBubble extends StatelessWidget { class OtherMessageBubble extends StatelessWidget { - const OtherMessageBubble({required this.message, super.key}); + OtherMessageBubble({required this.message, super.key}); final Message message; + final DateFormat _dateFormat = DateFormat.Hm(); @override Widget build(BuildContext context) { @@ -144,12 +207,22 @@ class OtherMessageBubble extends StatelessWidget { .secondaryContainer, margin: const EdgeInsets.only(right: 32, bottom: 16, left: 8), child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - content, - softWrap: true, - maxLines: null, - style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + content, + softWrap: true, + maxLines: null, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 6,), + Text( + _dateFormat.format(message.sendTime), + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), + ), + ], ), ), ), @@ -157,4 +230,15 @@ class OtherMessageBubble extends StatelessWidget { ], ); } +} + +class MessageStatusIndicator extends StatelessWidget { + const MessageStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + // TODO: implement build + throw UnimplementedError(); + } + } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 288d3c1..5c35d50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -152,6 +152,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" js: dependency: transitive description: @@ -168,6 +176,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + url: "https://pub.dev" + source: hosted + version: "1.1.1" matcher: dependency: transitive description: @@ -184,6 +200,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + message_pack_dart: + dependency: transitive + description: + name: message_pack_dart + sha256: "71b9f0ff60e5896e60b337960bb535380d7dba3297b457ac763ccae807385b59" + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: @@ -193,7 +217,7 @@ packages: source: hosted version: "1.8.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b @@ -208,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + signalr_netcore: + dependency: "direct main" + description: + name: signalr_netcore + sha256: bfc6e4cb95e3c2c1d9691e8c582a72e2b3fee4cd380abb060eaf65e3c5c43b29 + url: "https://pub.dev" + source: hosted + version: "1.3.3" sky_engine: dependency: transitive description: flutter @@ -221,6 +253,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + sse_client: + dependency: transitive + description: + name: sse_client + sha256: "71bd826430b41ab20a69d85bf2dfe9f11cfe222938e681ada1aea71fc8adf348" + url: "https://pub.dev" + source: hosted + version: "0.1.0" stack_trace: dependency: transitive description: @@ -261,6 +301,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" + tuple: + dependency: transitive + description: + name: tuple + sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa" + url: "https://pub.dev" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: @@ -285,6 +333,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" sdks: dart: ">=2.19.6 <3.0.0" flutter: ">=2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7b6e9b0..d42074b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,9 @@ dependencies: http: ^0.13.5 uuid: ^3.0.7 flutter_secure_storage: ^8.0.0 + intl: ^0.18.1 + path: ^1.8.2 + signalr_netcore: ^1.3.3 dev_dependencies: flutter_test: