Add user avatars

This commit is contained in:
Nutcake 2023-04-30 13:39:09 +02:00 committed by Nils Rother
parent 7b776db632
commit 8a3ff70523
11 changed files with 415 additions and 85 deletions

View file

@ -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<AuthenticationData> tryLogin({
required String username,
@ -104,7 +105,7 @@ class ApiClient {
}
Map<String, String> 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<void>? _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();
Future<void> sendMessage(Message message) async {
await hubConnection.send("SendMessage", args: [message.toMap()]);
}
}

View file

@ -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<Iterable<Friend>> getFriendsList() async {
final response = await BaseClient.client.get("/users/${BaseClient.client.userId}/friends");
class FriendApi {
static Future<Iterable<Friend>> 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));

View file

@ -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<Iterable<Message>> 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<Iterable<Message>> 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"}"

View file

@ -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<ContactsPlus> createState() => _ContactsPlusState();
}
class _ContactsPlusState extends State<ContactsPlus> {
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
AuthenticationData _authData = AuthenticationData.unauthenticated();
@override
Widget build(BuildContext context) {
return MaterialApp(
return ClientHolder(
authenticationData: _authData,
child: MaterialApp(
title: 'Contacts+',
theme: ThemeData(
textTheme: _typography.white,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
),
home: const SplashScreen(),
);
}
}
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
final ApiClient _apiClient = ApiClient();
@override
Widget build(BuildContext context) {
if (_apiClient.isAuthenticated) {
return const HomeScreen();
} else {
return LoginScreen(
home: _authData.isAuthenticated ?
const HomeScreen() :
LoginScreen(
onLoginSuccessful: (AuthenticationData authData) {
if (authData.isAuthenticated) {
setState(() {
_apiClient.authenticationData = authData;
_authData = authData;
});
}
},
),
),
);
}
}
class ClientHolder extends InheritedWidget {
final ApiClient client;
ClientHolder({super.key, required AuthenticationData authenticationData, required super.child})
: client = ApiClient(authenticationData: authenticationData);
static ClientHolder? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
}
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;
}

View file

@ -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

View file

@ -1,11 +1,30 @@
import 'dart:developer';
import 'package:uuid/uuid.dart';
enum MessageType {
unknown,
text,
sound,
sessionInvite,
object,
object;
static const Map<MessageType, String> _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()}";
}
}

View file

@ -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);
}
}

View file

@ -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<HomeScreen> {
Future<List<Friend>>? _friendsFuture;
ClientHolder? _clientHolder;
@override
void initState() {
super.initState();
void didChangeDependencies() {
super.didChangeDependencies();
final clientHolder = ClientHolder.of(context);
if (_clientHolder != clientHolder) {
_clientHolder = clientHolder;
_refreshFriendsList();
}
}
void _refreshFriendsList() {
_friendsFuture = FriendApi.getFriendsList().then((Iterable<Friend> value) =>
_friendsFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
value.toList()
..sort((a, b) {
if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) {
@ -40,6 +47,7 @@ class _HomeScreenState extends State<HomeScreen> {
@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<HomeScreen> {
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)));
},
);
},

View file

@ -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<Messages> {
Future<Iterable<Message>>? _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();
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<Messages> {
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),
),

View file

@ -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"

View file

@ -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: