Add chat message view

This commit is contained in:
Nutcake 2023-04-29 22:21:00 +02:00 committed by Nils Rother
parent a9a14c09b7
commit 4f67acec8e
9 changed files with 102 additions and 92 deletions

View file

@ -13,11 +13,20 @@ class ApiClient {
static const String tokenKey = "token"; static const String tokenKey = "token";
static const String passwordKey = "password"; static const String passwordKey = "password";
final AuthenticationData _authenticationData; static final ApiClient _singleton = ApiClient._internal();
String get userId => _authenticationData.userId; factory ApiClient() {
return _singleton;
}
const ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; ApiClient._internal();
AuthenticationData? _authenticationData;
set authenticationData(value) => _authenticationData = value;
String get userId => _authenticationData!.userId;
bool get isAuthenticated => _authenticationData?.isAuthenticated ?? false;
static Future<AuthenticationData> tryLogin({ static Future<AuthenticationData> tryLogin({
required String username, required String username,
@ -90,7 +99,7 @@ class ApiClient {
} }
Map<String, String> get authorizationHeader => { 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"); static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path");
@ -120,3 +129,7 @@ class ApiClient {
return http.delete(buildFullUri(path), headers: headers); return http.delete(buildFullUri(path), headers: headers);
} }
} }
class BaseClient {
static final client = ApiClient();
}

View file

@ -4,14 +4,9 @@ import 'dart:convert';
import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/api_client.dart';
import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/friend.dart';
class FriendApi { class FriendApi extends BaseClient {
static Future<Iterable<Friend>> getFriendsList() async {
const FriendApi({required apiClient}) : _apiClient = apiClient; final response = await BaseClient.client.get("/users/${BaseClient.client.userId}/friends");
final ApiClient _apiClient;
Future<Iterable<Friend>> getFriendsList() async {
final response = await _apiClient.get("/users/${_apiClient.userId}/friends");
ApiClient.checkResponse(response); ApiClient.checkResponse(response);
final data = jsonDecode(response.body) as List; final data = jsonDecode(response.body) as List;
return data.map((e) => Friend.fromMap(e)); return data.map((e) => Friend.fromMap(e));

View file

@ -3,14 +3,9 @@ import 'dart:convert';
import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/api_client.dart';
import 'package:contacts_plus/models/message.dart'; import 'package:contacts_plus/models/message.dart';
class MessageApi { class MessageApi extends BaseClient {
static Future<Iterable<Message>> getUserMessages({String userId="", DateTime? fromTime, int maxItems=50, bool unreadOnly=false}) async {
const MessageApi({required ApiClient apiClient}) : _apiClient = apiClient; final response = await BaseClient.client.get("/users/${BaseClient.client.userId}/messages"
final ApiClient _apiClient;
Future<Iterable<Message>> getUserMessages({String userId="", DateTime? fromTime, int maxItems=50, bool unreadOnly=false}) async {
final response = await _apiClient.get("/users/${_apiClient.userId}/messages"
"?maxItems=$maxItems" "?maxItems=$maxItems"
"${fromTime == null ? "" : "&fromTime${fromTime.toLocal().toIso8601String()}"}" "${fromTime == null ? "" : "&fromTime${fromTime.toLocal().toIso8601String()}"}"
"${userId.isEmpty ? "" : "&user=$userId"}" "${userId.isEmpty ? "" : "&user=$userId"}"

View file

@ -5,18 +5,20 @@ import 'api_client.dart';
import 'models/authentication_data.dart'; import 'models/authentication_data.dart';
void main() { void main() {
runApp(const ContactsPlus()); runApp(ContactsPlus());
} }
class ContactsPlus extends StatelessWidget { class ContactsPlus extends StatelessWidget {
const ContactsPlus({super.key}); ContactsPlus({super.key});
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Contacts+', title: 'Contacts+',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange, brightness: Brightness.dark) textTheme: _typography.white,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
), ),
home: const SplashScreen(), home: const SplashScreen(),
); );
@ -31,21 +33,18 @@ class SplashScreen extends StatefulWidget {
} }
class _SplashScreenState extends State<SplashScreen> { class _SplashScreenState extends State<SplashScreen> {
AuthenticationData _authenticationData = AuthenticationData.unauthenticated(); final ApiClient _apiClient = ApiClient();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_authenticationData.isAuthenticated) { if (_apiClient.isAuthenticated) {
return AuthenticatedClient( return const HomeScreen();
authenticationData: _authenticationData,
child: const HomeScreen(),
);
} else { } else {
return LoginScreen( return LoginScreen(
onLoginSuccessful: (AuthenticationData authData) { onLoginSuccessful: (AuthenticationData authData) {
if (authData.isAuthenticated) { if (authData.isAuthenticated) {
setState(() { setState(() {
_authenticationData = authData; _apiClient.authenticationData = authData;
}); });
} }
}, },
@ -53,29 +52,3 @@ class _SplashScreenState extends State<SplashScreen> {
} }
} }
} }
class AuthenticatedClient extends InheritedWidget {
final ApiClient client;
AuthenticatedClient({super.key, required AuthenticationData authenticationData, required super.child})
: client = ApiClient(authenticationData: authenticationData);
static AuthenticatedClient? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AuthenticatedClient>();
}
static AuthenticatedClient of(BuildContext context) {
final AuthenticatedClient? result = maybeOf(context);
assert(result != null, 'No AuthenticatedClient found in context');
return result!;
}
static AuthenticatedClient staticOf(BuildContext context) {
final result = context.findAncestorWidgetOfExactType<AuthenticatedClient>();
assert(result != null, 'No AuthenticatedClient found in context');
return result!;
}
@override
bool updateShouldNotify(covariant AuthenticatedClient oldWidget) => oldWidget.client != client;
}

View file

@ -43,7 +43,7 @@ class UserStatus {
factory UserStatus.fromMap(Map map) { factory UserStatus.fromMap(Map map) {
final statusString = map["onlineStatus"] as String?; final statusString = map["onlineStatus"] as String?;
final status = OnlineStatus.values.firstWhere((element) => element.name == statusString?.toLowerCase(), final status = OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == statusString?.toLowerCase(),
orElse: () => OnlineStatus.unknown, orElse: () => OnlineStatus.unknown,
); );
if (status == OnlineStatus.unknown && statusString != null) { if (status == OnlineStatus.unknown && statusString != null) {

View file

@ -4,6 +4,8 @@ enum MessageType {
unknown, unknown,
text, text,
sound, sound,
sessionInvite,
object,
} }
class Message { class Message {
@ -17,7 +19,7 @@ class Message {
factory Message.fromMap(Map map) { factory Message.fromMap(Map map) {
final typeString = map["messageType"] as String?; final typeString = map["messageType"] as String?;
final type = MessageType.values.firstWhere((element) => element.name == typeString?.toLowerCase(), final type = MessageType.values.firstWhere((element) => element.name.toLowerCase() == typeString?.toLowerCase(),
orElse: () => MessageType.unknown, orElse: () => MessageType.unknown,
); );
if (type == MessageType.unknown && typeString != null) { if (type == MessageType.unknown && typeString != null) {
@ -25,8 +27,8 @@ class Message {
} }
return Message( return Message(
id: map["id"], id: map["id"],
recipientId: map["recipient_id"], recipientId: map["recipientId"],
senderId: map["sender_id"], senderId: map["senderId"],
type: type, type: type,
content: map["content"], content: map["content"],
); );

View file

@ -12,21 +12,16 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
late final FriendApi _friendsApi;
Future<List<Friend>>? _friendsFuture; Future<List<Friend>>? _friendsFuture;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_friendsApi = FriendApi(apiClient: AuthenticatedClient
.staticOf(context)
.client);
_refreshFriendsList(); _refreshFriendsList();
} }
void _refreshFriendsList() { void _refreshFriendsList() {
_friendsFuture = _friendsApi.getFriendsList().then((Iterable<Friend> value) => _friendsFuture = FriendApi.getFriendsList().then((Iterable<Friend> value) =>
value.toList() value.toList()
..sort((a, b) { ..sort((a, b) {
if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) { if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) {

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/apis/message_api.dart';
import 'package:contacts_plus/main.dart';
import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/friend.dart';
import 'package:contacts_plus/models/message.dart'; import 'package:contacts_plus/models/message.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -16,26 +16,20 @@ class Messages extends StatefulWidget {
class _MessagesState extends State<Messages> { class _MessagesState extends State<Messages> {
Future<Iterable<Message>>? _messagesFuture; Future<Iterable<Message>>? _messagesFuture;
late final MessageApi _messageApi;
void _refreshMessages() { void _refreshMessages() {
_messagesFuture = _messageApi.getUserMessages(userId: widget.friend.id)..then((value) => value.toList()); _messagesFuture = MessageApi.getUserMessages(userId: widget.friend.id)..then((value) => value.toList());
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_messageApi = MessageApi(
apiClient: AuthenticatedClient
.staticOf(context)
.client,
);
_refreshMessages(); _refreshMessages();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final apiClient = AuthenticatedClient.of(context).client; final apiClient = ApiClient();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.friend.username), title: Text(widget.friend.username),
@ -46,14 +40,13 @@ class _MessagesState extends State<Messages> {
if (snapshot.hasData) { if (snapshot.hasData) {
final data = snapshot.data as Iterable<Message>; final data = snapshot.data as Iterable<Message>;
return ListView.builder( return ListView.builder(
reverse: true,
itemCount: data.length, itemCount: data.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = data.elementAt(index); final entry = data.elementAt(index);
if (entry.senderId == apiClient.userId) { return entry.senderId == apiClient.userId
return MyMessageBubble(message: entry); ? MyMessageBubble(message: entry)
} else { : OtherMessageBubble(message: entry);
return OtherMessageBubble(message: entry);
}
}, },
); );
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
@ -85,15 +78,35 @@ class MyMessageBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = message.content;
if (message.type == MessageType.sessionInvite) {
content = "<Session Invite>";
} else if (message.type == MessageType.sound) {
content = "<Voice Message>";
} else if (message.type == MessageType.object) {
content = "<Asset>";
}
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Flexible(
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
margin: const EdgeInsets.only(left:16), margin: const EdgeInsets.only(left: 32, bottom: 16, right: 8),
padding: const EdgeInsets.all(12), child: Padding(
child: Text(message.content, softWrap: true,), padding: const EdgeInsets.all(16),
child: Text(
content,
softWrap: true,
maxLines: null,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
), ),
], ],
); );
@ -108,14 +121,38 @@ class OtherMessageBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = message.content;
if (message.type == MessageType.sessionInvite) {
content = "<Session Invite>";
} else if (message.type == MessageType.sound) {
content = "<Voice Message>";
} else if (message.type == MessageType.object) {
content = "<Asset>";
}
return Row( return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Container( Flexible(
color: Theme.of(context).colorScheme.secondaryContainer, child: Card(
margin: const EdgeInsets.only(right: 16), shape: RoundedRectangleBorder(
padding: const EdgeInsets.all(12), borderRadius: BorderRadius.circular(16),
child: Text(message.content, softWrap: true,), ),
color: Theme
.of(context)
.colorScheme
.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,
),
),
),
), ),
], ],
); );

View file

@ -13,7 +13,7 @@ import 'package:contacts_plus/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const ContactsPlus()); await tester.pumpWidget(ContactsPlus());
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);