diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24476c5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..fec9967 --- /dev/null +++ b/.metadata @@ -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' diff --git a/lib/api_client.dart b/lib/api_client.dart index f741649..fb62e01 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -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 tryLogin({required String username, required String password, bool rememberMe=false}) async { + static Future 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 get authorizationHeader => { "Authorization": "neos ${_authenticationData.userId}:${_authenticationData.token}" }; - Future get(Uri uri, {Map? headers}) { + static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path"); + + Future get(String path, {Map? headers}) { headers ??= {}; headers.addAll(authorizationHeader); - return http.get(uri, headers: headers); + return http.get(buildFullUri(path), headers: headers); } - Future post(Uri uri, {Object? body, Map? headers}) { + Future post(String path, {Object? body, Map? 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 put(Uri uri, {Object? body, Map? headers}) { + Future put(String path, {Object? body, Map? headers}) { headers ??= {}; headers.addAll(authorizationHeader); - return http.put(uri, headers: headers, body: body); + return http.put(buildFullUri(path), headers: headers, body: body); } - Future delete(Uri uri, {Map? headers}) { + Future delete(String path, {Map? headers}) { headers ??= {}; headers.addAll(authorizationHeader); - return http.delete(uri, headers: headers); + return http.delete(buildFullUri(path), headers: headers); } } \ No newline at end of file diff --git a/lib/apis/friend_api.dart b/lib/apis/friend_api.dart new file mode 100644 index 0000000..f523dea --- /dev/null +++ b/lib/apis/friend_api.dart @@ -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> 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)); + } +} \ No newline at end of file diff --git a/lib/apis/friends_api.dart b/lib/apis/friends_api.dart deleted file mode 100644 index 2ea97cc..0000000 --- a/lib/apis/friends_api.dart +++ /dev/null @@ -1,9 +0,0 @@ - -import 'package:contacts_plus/models/friend.dart'; - -class FriendsApi { - - static Future> getFriendsList() async { - return []; - } -} \ No newline at end of file diff --git a/lib/apis/message_api.dart b/lib/apis/message_api.dart new file mode 100644 index 0000000..8106517 --- /dev/null +++ b/lib/apis/message_api.dart @@ -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> 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)); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 71e3ffb..18c49f3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -70,6 +70,12 @@ class AuthenticatedClient extends InheritedWidget { return result!; } + static AuthenticatedClient staticOf(BuildContext context) { + final result = context.findAncestorWidgetOfExactType(); + assert(result != null, 'No AuthenticatedClient found in context'); + return result!; + } + @override bool updateShouldNotify(covariant AuthenticatedClient oldWidget) => oldWidget.client != client; } \ No newline at end of file diff --git a/lib/models/authentication_data.dart b/lib/models/authentication_data.dart index cc9fbad..8af1366 100644 --- a/lib/models/authentication_data.dart +++ b/lib/models/authentication_data.dart @@ -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; } diff --git a/lib/models/friend.dart b/lib/models/friend.dart index 2a41187..8535c13 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -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"]), + ); + } } \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..7adea25 --- /dev/null +++ b/lib/models/message.dart @@ -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"], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/home_screen.dart b/lib/widgets/home_screen.dart index ef723ad..1cf6fe3 100644 --- a/lib/widgets/home_screen.dart +++ b/lib/widgets/home_screen.dart @@ -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 { + late final FriendApi _friendsApi; + Future>? _friendsFuture; + + @override + void initState() { + super.initState(); + _friendsApi = FriendApi(apiClient: AuthenticatedClient + .staticOf(context) + .client); + _refreshFriendsList(); + } + + void _refreshFriendsList() { + _friendsFuture = _friendsApi.getFriendsList().then((Iterable 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 { 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; + 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(); + } } - } + ), ), ); } diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart new file mode 100644 index 0000000..ee27847 --- /dev/null +++ b/lib/widgets/messages.dart @@ -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 createState() => _MessagesState(); + +} + +class _MessagesState extends State { + Future>? _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; + 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,), + ), + ], + ); + } +} \ No newline at end of file