Add chat message view
This commit is contained in:
parent
a9a14c09b7
commit
4f67acec8e
9 changed files with 102 additions and 92 deletions
|
@ -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();
|
||||||
|
}
|
|
@ -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));
|
||||||
|
|
|
@ -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"}"
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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"],
|
||||||
);
|
);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue