Add basic friends list

This commit is contained in:
Nutcake 2023-04-29 21:26:12 +02:00 committed by Nils Rother
parent 8aeab5ba28
commit a9a14c09b7
12 changed files with 448 additions and 58 deletions

44
.gitignore vendored Normal file
View file

@ -0,0 +1,44 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

33
.metadata Normal file
View file

@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
- platform: android
create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
- platform: linux
create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View file

@ -11,12 +11,20 @@ class ApiClient {
static const String userIdKey = "userId";
static const String machineIdKey = "machineId";
static const String tokenKey = "token";
static const String passwordKey = "password";
final AuthenticationData _authenticationData;
String get userId => _authenticationData.userId;
const ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
static Future<AuthenticationData> tryLogin({required String username, required String password, bool rememberMe=false}) async {
static Future<AuthenticationData> tryLogin({
required String username,
required String password,
bool rememberMe=true,
bool rememberPass=false
}) async {
final body = {
"username": username,
"password": password,
@ -24,21 +32,21 @@ class ApiClient {
"secretMachineId": const Uuid().v4(),
};
final response = await http.post(
Uri.parse("${Config.apiBaseUrl}/api/UserSessions"),
buildFullUri("/UserSessions"),
headers: {"Content-Type": "application/json"},
body: jsonEncode(body));
if (response.statusCode == 400) {
throw "Invalid Credentials";
} else if (response.statusCode != 200) {
throw "Unknown Error${kDebugMode ? ": ${response.statusCode}|${response.body}" : ""}";
}
}
checkResponse(response);
final authData = AuthenticationData.fromJson(jsonDecode(response.body));
final authData = AuthenticationData.fromMap(jsonDecode(response.body));
if (authData.isAuthenticated) {
const FlutterSecureStorage storage = FlutterSecureStorage();
await storage.write(key: userIdKey, value: authData.userId);
await storage.write(key: machineIdKey, value: authData.secretMachineId);
await storage.write(key: tokenKey, value: authData.token);
if (rememberPass) await storage.write(key: passwordKey, value: password);
}
return authData;
}
@ -48,46 +56,67 @@ class ApiClient {
String? userId = await storage.read(key: userIdKey);
String? machineId = await storage.read(key: machineIdKey);
String? token = await storage.read(key: tokenKey);
String? password = await storage.read(key: passwordKey);
if (userId == null || machineId == null || token == null) {
if (userId == null || machineId == null) {
return AuthenticationData.unauthenticated();
}
final response = await http.get(Uri.parse("${Config.apiBaseUrl}/api/users/$userId"), headers: {
"Authorization": "neos $userId:$token"
});
if (response.statusCode == 200) {
return AuthenticationData(userId: userId, token: token, secretMachineId: machineId, isAuthenticated: true);
if (token != null) {
final response = await http.get(buildFullUri("/users/$userId"), headers: {
"Authorization": "neos $userId:$token"
});
if (response.statusCode == 200) {
return AuthenticationData(userId: userId, token: token, secretMachineId: machineId, isAuthenticated: true);
}
}
if (password != null) {
try {
userId = userId.startsWith("U-") ? userId.replaceRange(0, 2, "") : userId;
final loginResult = await tryLogin(username: userId, password: password, rememberPass: true);
if (loginResult.isAuthenticated) return loginResult;
} catch (_) {
// We don't need to notify the user if the cached login fails behind the scenes, so just ignore any exceptions.
}
}
return AuthenticationData.unauthenticated();
}
static void checkResponse(http.Response response) {
if (response.statusCode != 200) {
throw "Unknown Error${kDebugMode ? ": ${response.statusCode}|${response.body}" : ""}";
}
}
Map<String, String> get authorizationHeader => {
"Authorization": "neos ${_authenticationData.userId}:${_authenticationData.token}"
};
Future<http.Response> get(Uri uri, {Map<String, String>? headers}) {
static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path");
Future<http.Response> get(String path, {Map<String, String>? headers}) {
headers ??= {};
headers.addAll(authorizationHeader);
return http.get(uri, headers: headers);
return http.get(buildFullUri(path), headers: headers);
}
Future<http.Response> post(Uri uri, {Object? body, Map<String, String>? headers}) {
Future<http.Response> post(String path, {Object? body, Map<String, String>? headers}) {
headers ??= {};
headers["Content-Type"] = "application/json";
headers.addAll(authorizationHeader);
return http.post(uri, headers: headers, body: body);
return http.post(buildFullUri(path), headers: headers, body: body);
}
Future<http.Response> put(Uri uri, {Object? body, Map<String, String>? headers}) {
Future<http.Response> put(String path, {Object? body, Map<String, String>? headers}) {
headers ??= {};
headers.addAll(authorizationHeader);
return http.put(uri, headers: headers, body: body);
return http.put(buildFullUri(path), headers: headers, body: body);
}
Future<http.Response> delete(Uri uri, {Map<String, String>? headers}) {
Future<http.Response> delete(String path, {Map<String, String>? headers}) {
headers ??= {};
headers.addAll(authorizationHeader);
return http.delete(uri, headers: headers);
return http.delete(buildFullUri(path), headers: headers);
}
}

19
lib/apis/friend_api.dart Normal file
View file

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

View file

@ -1,9 +0,0 @@
import 'package:contacts_plus/models/friend.dart';
class FriendsApi {
static Future<List<Friend>> getFriendsList() async {
return [];
}
}

23
lib/apis/message_api.dart Normal file
View file

@ -0,0 +1,23 @@
import 'dart:convert';
import 'package:contacts_plus/api_client.dart';
import 'package:contacts_plus/models/message.dart';
class MessageApi {
const MessageApi({required ApiClient apiClient}) : _apiClient = apiClient;
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"
"${fromTime == null ? "" : "&fromTime${fromTime.toLocal().toIso8601String()}"}"
"${userId.isEmpty ? "" : "&user=$userId"}"
"&unread=$unreadOnly"
);
ApiClient.checkResponse(response);
final data = jsonDecode(response.body) as List;
return data.map((e) => Message.fromMap(e));
}
}

View file

@ -70,6 +70,12 @@ class AuthenticatedClient extends InheritedWidget {
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

@ -9,10 +9,10 @@ class AuthenticationData {
required this.userId, required this.token, required this.secretMachineId, required this.isAuthenticated
});
factory AuthenticationData.fromJson(Map json) {
final userId = json["userId"];
final token = json["token"];
final machineId = json["secretMachineId"];
factory AuthenticationData.fromMap(Map map) {
final userId = map["userId"];
final token = map["token"];
final machineId = map["secretMachineId"];
if (userId == null || token == null || machineId == null) {
return _unauthenticated;
}

View file

@ -1,13 +1,37 @@
class Friend {
import 'dart:developer';
import 'package:flutter/foundation.dart';
class Friend extends Comparable {
final String id;
final String username;
final UserStatus userStatus;
Friend({required this.id, required this.username, required this.userStatus});
factory Friend.fromMap(Map map) {
return Friend(id: map["id"], username: map["friendUsername"], userStatus: UserStatus.fromMap(map["userStatus"]));
}
@override
int compareTo(other) {
if (userStatus.onlineStatus == other.userStatus.onlineStatus) {
return userStatus.lastStatusChange.compareTo(other.userStatus.lastStatusChange);
} else {
if (userStatus.onlineStatus == OnlineStatus.online) {
return -1;
} else {
return 1;
}
}
}
}
enum OnlineStatus {
unknown,
offline,
away,
busy,
online,
}
@ -16,4 +40,18 @@ class UserStatus {
final DateTime lastStatusChange;
UserStatus({required this.onlineStatus, required this.lastStatusChange});
factory UserStatus.fromMap(Map map) {
final statusString = map["onlineStatus"] as String?;
final status = OnlineStatus.values.firstWhere((element) => element.name == statusString?.toLowerCase(),
orElse: () => OnlineStatus.unknown,
);
if (status == OnlineStatus.unknown && statusString != null) {
log("Unknown OnlineStatus '$statusString' in response");
}
return UserStatus(
onlineStatus: status,
lastStatusChange: DateTime.parse(map["lastStatusChange"]),
);
}
}

34
lib/models/message.dart Normal file
View file

@ -0,0 +1,34 @@
import 'dart:developer';
enum MessageType {
unknown,
text,
sound,
}
class Message {
final String id;
final String recipientId;
final String senderId;
final MessageType type;
final String content;
Message({required this.id, required this.recipientId, required this.senderId, required this.type, required this.content});
factory Message.fromMap(Map map) {
final typeString = map["messageType"] as String?;
final type = MessageType.values.firstWhere((element) => element.name == typeString?.toLowerCase(),
orElse: () => MessageType.unknown,
);
if (type == MessageType.unknown && typeString != null) {
log("Unknown MessageType '$typeString' in response");
}
return Message(
id: map["id"],
recipientId: map["recipient_id"],
senderId: map["sender_id"],
type: type,
content: map["content"],
);
}
}

View file

@ -1,3 +1,7 @@
import 'package:contacts_plus/apis/friend_api.dart';
import 'package:contacts_plus/main.dart';
import 'package:contacts_plus/models/friend.dart';
import 'package:contacts_plus/widgets/messages.dart';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
@ -9,6 +13,35 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
late final FriendApi _friendsApi;
Future<List<Friend>>? _friendsFuture;
@override
void initState() {
super.initState();
_friendsApi = FriendApi(apiClient: AuthenticatedClient
.staticOf(context)
.client);
_refreshFriendsList();
}
void _refreshFriendsList() {
_friendsFuture = _friendsApi.getFriendsList().then((Iterable<Friend> value) =>
value.toList()
..sort((a, b) {
if (a.userStatus.onlineStatus == b.userStatus.onlineStatus) {
return a.userStatus.lastStatusChange.compareTo(b.userStatus.lastStatusChange);
} else {
if (a.userStatus.onlineStatus == OnlineStatus.online) {
return -1;
} else {
return 1;
}
}
},
),
);
}
@override
Widget build(BuildContext context) {
@ -16,31 +49,48 @@ class _HomeScreenState extends State<HomeScreen> {
appBar: AppBar(
title: const Text("Contacts+"),
),
body: FutureBuilder(
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemBuilder: (context, index) {
},
);
} else if (snapshot.hasError) {
return Center(child: Padding(
padding: const EdgeInsets.all(64),
child: Text(
"Something went wrong: ${snapshot.error}",
softWrap: true,
style: Theme
.of(context)
.textTheme
.labelMedium,
),
),
);
} else {
return const LinearProgressIndicator();
body: RefreshIndicator(
onRefresh: () async {
_refreshFriendsList();
await _friendsFuture;
},
child: FutureBuilder(
future: _friendsFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
final data = snapshot.data as Iterable<Friend>;
return ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final entry = data.elementAt(index);
return ListTile(
title: Text(entry.username),
subtitle: Text(entry.userStatus.onlineStatus.name),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => Messages(friend: entry)));
},
);
},
);
} else if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(64),
child: Text(
"Something went wrong: ${snapshot.error}",
softWrap: true,
style: Theme
.of(context)
.textTheme
.labelMedium,
),
),
);
} else {
return const LinearProgressIndicator();
}
}
}
),
),
);
}

123
lib/widgets/messages.dart Normal file
View file

@ -0,0 +1,123 @@
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';
class Messages extends StatefulWidget {
const Messages({required this.friend, super.key});
final Friend friend;
@override
State<StatefulWidget> createState() => _MessagesState();
}
class _MessagesState extends State<Messages> {
Future<Iterable<Message>>? _messagesFuture;
late final MessageApi _messageApi;
void _refreshMessages() {
_messagesFuture = _messageApi.getUserMessages(userId: widget.friend.id)..then((value) => value.toList());
}
@override
void initState() {
super.initState();
_messageApi = MessageApi(
apiClient: AuthenticatedClient
.staticOf(context)
.client,
);
_refreshMessages();
}
@override
Widget build(BuildContext context) {
final apiClient = AuthenticatedClient.of(context).client;
return Scaffold(
appBar: AppBar(
title: Text(widget.friend.username),
),
body: FutureBuilder(
future: _messagesFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
final data = snapshot.data as Iterable<Message>;
return ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final entry = data.elementAt(index);
if (entry.senderId == apiClient.userId) {
return MyMessageBubble(message: entry);
} else {
return OtherMessageBubble(message: entry);
}
},
);
} else if (snapshot.hasError) {
return Column(
children: [
Text("Failed to load messages:\n${snapshot.error}"),
TextButton.icon(
onPressed: () {
},
icon: const Icon(Icons.refresh),
label: const Text("Retry"),
),
],
);
} else {
return const LinearProgressIndicator();
}
},
),
);
}
}
class MyMessageBubble extends StatelessWidget {
const MyMessageBubble({required this.message, super.key});
final Message message;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.max,
children: [
Container(
color: Theme.of(context).colorScheme.primaryContainer,
margin: const EdgeInsets.only(left:16),
padding: const EdgeInsets.all(12),
child: Text(message.content, softWrap: true,),
),
],
);
}
}
class OtherMessageBubble extends StatelessWidget {
const OtherMessageBubble({required this.message, super.key});
final Message message;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
color: Theme.of(context).colorScheme.secondaryContainer,
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.all(12),
child: Text(message.content, softWrap: true,),
),
],
);
}
}