Add basic friends list
This commit is contained in:
parent
8aeab5ba28
commit
a9a14c09b7
12 changed files with 448 additions and 58 deletions
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
33
.metadata
Normal 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'
|
|
@ -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: {
|
||||
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
19
lib/apis/friend_api.dart
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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
23
lib/apis/message_api.dart
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
34
lib/models/message.dart
Normal 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"],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,16 +49,32 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
appBar: AppBar(
|
||||
title: const Text("Contacts+"),
|
||||
),
|
||||
body: FutureBuilder(
|
||||
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(
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(64),
|
||||
child: Text(
|
||||
"Something went wrong: ${snapshot.error}",
|
||||
|
@ -42,6 +91,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
}
|
||||
}
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
123
lib/widgets/messages.dart
Normal file
123
lib/widgets/messages.dart
Normal 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,),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue