Add user session list dropdown
This commit is contained in:
parent
da12adbfaa
commit
37ad7b7438
15 changed files with 358 additions and 100 deletions
|
@ -9,7 +9,6 @@ import 'package:signalr_netcore/http_connection_options.dart';
|
||||||
import 'package:signalr_netcore/hub_connection.dart';
|
import 'package:signalr_netcore/hub_connection.dart';
|
||||||
import 'package:signalr_netcore/hub_connection_builder.dart';
|
import 'package:signalr_netcore/hub_connection_builder.dart';
|
||||||
import 'package:signalr_netcore/ihub_protocol.dart';
|
import 'package:signalr_netcore/ihub_protocol.dart';
|
||||||
import 'package:signalr_netcore/msgpack_hub_protocol.dart';
|
|
||||||
import 'package:signalr_netcore/web_supporting_http_client.dart';
|
import 'package:signalr_netcore/web_supporting_http_client.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
@ -24,7 +23,7 @@ class ApiClient {
|
||||||
|
|
||||||
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData {
|
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData {
|
||||||
if (_authenticationData.isAuthenticated) {
|
if (_authenticationData.isAuthenticated) {
|
||||||
hub.start();
|
//hub.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,6 +159,7 @@ class NeosHub {
|
||||||
hubConnection.onreconnected(({connectionId}) {
|
hubConnection.onreconnected(({connectionId}) {
|
||||||
log("onreconnected called");
|
log("onreconnected called");
|
||||||
});
|
});
|
||||||
|
hubConnection.on("ReceiveMessage", _handleReceiveMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
|
@ -171,4 +171,12 @@ class NeosHub {
|
||||||
Future<void> sendMessage(Message message) async {
|
Future<void> sendMessage(Message message) async {
|
||||||
await hubConnection.send("SendMessage", args: [message.toMap()]);
|
await hubConnection.send("SendMessage", args: [message.toMap()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleReceiveMessage(List<Object?>? params) {
|
||||||
|
log("Message received.");
|
||||||
|
if (params == null) return;
|
||||||
|
for(var obj in params) {
|
||||||
|
log("$obj");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ 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';
|
||||||
|
import 'package:contacts_plus/models/user.dart';
|
||||||
|
|
||||||
class FriendApi {
|
class FriendApi {
|
||||||
static Future<Iterable<Friend>> getFriendsList(ApiClient client) async {
|
static Future<Iterable<Friend>> getFriendsList(ApiClient client) async {
|
||||||
|
@ -11,4 +12,9 @@ class FriendApi {
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> addFriend(ApiClient client, {required User user}) async {
|
||||||
|
final response = await client.put("/users/${client.userId}/friends/${user.id}", body: user.toMap());
|
||||||
|
ApiClient.checkResponse(response);
|
||||||
|
}
|
||||||
}
|
}
|
12
lib/aux.dart
12
lib/aux.dart
|
@ -41,3 +41,15 @@ extension NeosStringExtensions on Uri {
|
||||||
return Uri.parse(base + signature);
|
return Uri.parse(base + signature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Aux {
|
||||||
|
static String neosDbToHttp(String neosdb) {
|
||||||
|
final fullUri = neosdb.replaceFirst("neosdb:///", Config.neosCdnUrl);
|
||||||
|
final lastPeriodIndex = fullUri.lastIndexOf(".");
|
||||||
|
if (lastPeriodIndex != -1 && fullUri.length - lastPeriodIndex < 8) {
|
||||||
|
// I feel like 8 is a good maximum for file extension length? Can neosdb Uris even come without file extensions?
|
||||||
|
return fullUri.substring(0, lastPeriodIndex);
|
||||||
|
}
|
||||||
|
return fullUri;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,4 +6,6 @@ class Config {
|
||||||
static const String neosCdnUrl = "https://cloudx.azureedge.net/assets/";
|
static const String neosCdnUrl = "https://cloudx.azureedge.net/assets/";
|
||||||
static const String neosAssetsUrl = "https://cloudxstorage.blob.core.windows.net/assets/";
|
static const String neosAssetsUrl = "https://cloudxstorage.blob.core.windows.net/assets/";
|
||||||
static const String neosHubUrl = "$apiBaseUrl/hub";
|
static const String neosHubUrl = "$apiBaseUrl/hub";
|
||||||
|
|
||||||
|
static const int messageCacheValiditySeconds = 90;
|
||||||
}
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:contacts_plus/models/message.dart';
|
||||||
import 'package:contacts_plus/widgets/home_screen.dart';
|
import 'package:contacts_plus/widgets/home_screen.dart';
|
||||||
import 'package:contacts_plus/widgets/login_screen.dart';
|
import 'package:contacts_plus/widgets/login_screen.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -18,27 +19,33 @@ class ContactsPlus extends StatefulWidget {
|
||||||
class _ContactsPlusState extends State<ContactsPlus> {
|
class _ContactsPlusState extends State<ContactsPlus> {
|
||||||
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
||||||
AuthenticationData _authData = AuthenticationData.unauthenticated();
|
AuthenticationData _authData = AuthenticationData.unauthenticated();
|
||||||
|
final Map<String, MessageCache> _messageCache = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClientHolder(
|
return ClientHolder(
|
||||||
authenticationData: _authData,
|
authenticationData: _authData,
|
||||||
child: MaterialApp(
|
child: MessageCacheHolder(
|
||||||
title: 'Contacts+',
|
messageCache: _messageCache,
|
||||||
theme: ThemeData(
|
child: MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
title: 'Contacts+',
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
textTheme: _typography.white,
|
textTheme: _typography.white,
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
|
||||||
),
|
),
|
||||||
home: _authData.isAuthenticated ?
|
home: _authData.isAuthenticated ?
|
||||||
const HomeScreen() :
|
const HomeScreen() :
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
onLoginSuccessful: (AuthenticationData authData) {
|
onLoginSuccessful: (AuthenticationData authData) {
|
||||||
if (authData.isAuthenticated) {
|
if (authData.isAuthenticated) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_authData = authData;
|
_authData = authData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -64,3 +71,32 @@ class ClientHolder extends InheritedWidget {
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client;
|
bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCacheHolder extends InheritedWidget {
|
||||||
|
const MessageCacheHolder({super.key, required Map<String, MessageCache> messageCache, required super.child})
|
||||||
|
: _messageCache = messageCache;
|
||||||
|
|
||||||
|
final Map<String, MessageCache> _messageCache;
|
||||||
|
|
||||||
|
MessageCache? getCache(String index) => _messageCache[index];
|
||||||
|
|
||||||
|
void setCache(String index, List<Message> messages) {
|
||||||
|
_messageCache[index]?.invalidate();
|
||||||
|
_messageCache[index] = MessageCache(messages: messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageCacheHolder? maybeOf(BuildContext context) {
|
||||||
|
return context.dependOnInheritedWidgetOfExactType<MessageCacheHolder>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageCacheHolder of(BuildContext context) {
|
||||||
|
final MessageCacheHolder? result = maybeOf(context);
|
||||||
|
assert(result != null, 'No MessageCacheHolder found in context');
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
|
||||||
|
|
||||||
|
}
|
|
@ -7,15 +7,19 @@ class Friend extends Comparable {
|
||||||
final String username;
|
final String username;
|
||||||
final UserStatus userStatus;
|
final UserStatus userStatus;
|
||||||
final UserProfile userProfile;
|
final UserProfile userProfile;
|
||||||
|
final FriendStatus friendStatus;
|
||||||
|
|
||||||
Friend({required this.id, required this.username, required this.userStatus, required this.userProfile});
|
Friend({required this.id, required this.username, required this.userStatus, required this.userProfile,
|
||||||
|
required this.friendStatus,
|
||||||
|
});
|
||||||
|
|
||||||
factory Friend.fromMap(Map map) {
|
factory Friend.fromMap(Map map) {
|
||||||
return Friend(
|
return Friend(
|
||||||
id: map["id"],
|
id: map["id"],
|
||||||
username: map["friendUsername"],
|
username: map["friendUsername"],
|
||||||
userStatus: UserStatus.fromMap(map["userStatus"]),
|
userStatus: UserStatus.fromMap(map["userStatus"]),
|
||||||
userProfile: UserProfile.fromMap(map["profile"] ?? {"iconUrl": ""}),
|
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
|
||||||
|
friendStatus: FriendStatus.fromString(map["friendStatus"]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,31 +37,89 @@ class Friend extends Comparable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Session {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final List<SessionUser> sessionUsers;
|
||||||
|
final String thumbnail;
|
||||||
|
|
||||||
|
Session({required this.id, required this.name, required this.sessionUsers, required this.thumbnail});
|
||||||
|
|
||||||
|
factory Session.fromMap(Map map) {
|
||||||
|
return Session(
|
||||||
|
id: map["sessionId"],
|
||||||
|
name: map["name"],
|
||||||
|
sessionUsers: (map["sessionUsers"] as List? ?? []).map((entry) => SessionUser.fromMap(entry)).toList(),
|
||||||
|
thumbnail: map["thumbnail"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionUser {
|
||||||
|
final String id;
|
||||||
|
final String username;
|
||||||
|
final bool isPresent;
|
||||||
|
final int outputDevice;
|
||||||
|
|
||||||
|
SessionUser({required this.id, required this.username, required this.isPresent, required this.outputDevice});
|
||||||
|
|
||||||
|
factory SessionUser.fromMap(Map map) {
|
||||||
|
return SessionUser(
|
||||||
|
id: map["userID"],
|
||||||
|
username: map["username"],
|
||||||
|
isPresent: map["isPresent"],
|
||||||
|
outputDevice: map["outputDevice"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FriendStatus {
|
||||||
|
none,
|
||||||
|
searchResult,
|
||||||
|
requested,
|
||||||
|
ignored,
|
||||||
|
blocked,
|
||||||
|
accepted;
|
||||||
|
|
||||||
|
factory FriendStatus.fromString(String text) {
|
||||||
|
return FriendStatus.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(),
|
||||||
|
orElse: () => FriendStatus.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum OnlineStatus {
|
enum OnlineStatus {
|
||||||
unknown,
|
unknown,
|
||||||
offline,
|
offline,
|
||||||
away,
|
away,
|
||||||
busy,
|
busy,
|
||||||
online,
|
online;
|
||||||
|
|
||||||
|
factory OnlineStatus.fromString(String? text) {
|
||||||
|
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||||
|
orElse: () => OnlineStatus.unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserStatus {
|
class UserStatus {
|
||||||
final OnlineStatus onlineStatus;
|
final OnlineStatus onlineStatus;
|
||||||
final DateTime lastStatusChange;
|
final DateTime lastStatusChange;
|
||||||
|
final List<Session> activeSessions;
|
||||||
|
|
||||||
UserStatus({required this.onlineStatus, required this.lastStatusChange});
|
|
||||||
|
UserStatus({required this.onlineStatus, required this.lastStatusChange, required this.activeSessions});
|
||||||
|
|
||||||
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.toLowerCase() == statusString?.toLowerCase(),
|
final status = OnlineStatus.fromString(statusString);
|
||||||
orElse: () => OnlineStatus.unknown,
|
|
||||||
);
|
|
||||||
if (status == OnlineStatus.unknown && statusString != null) {
|
if (status == OnlineStatus.unknown && statusString != null) {
|
||||||
log("Unknown OnlineStatus '$statusString' in response");
|
log("Unknown OnlineStatus '$statusString' in response");
|
||||||
}
|
}
|
||||||
return UserStatus(
|
return UserStatus(
|
||||||
onlineStatus: status,
|
onlineStatus: status,
|
||||||
lastStatusChange: DateTime.parse(map["lastStatusChange"]),
|
lastStatusChange: DateTime.parse(map["lastStatusChange"]),
|
||||||
|
activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:contacts_plus/config.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
enum MessageType {
|
enum MessageType {
|
||||||
|
@ -68,3 +70,18 @@ class Message {
|
||||||
return "MSG-${const Uuid().v4()}";
|
return "MSG-${const Uuid().v4()}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MessageCache {
|
||||||
|
late final Timer _timer;
|
||||||
|
final List<Message> _messages;
|
||||||
|
bool get isValid => _timer.isActive;
|
||||||
|
|
||||||
|
List<Message> get messages => _messages;
|
||||||
|
|
||||||
|
MessageCache({required List<Message> messages})
|
||||||
|
: _messages = messages, _timer = Timer(const Duration(seconds: Config.messageCacheValiditySeconds),() {});
|
||||||
|
|
||||||
|
void invalidate() {
|
||||||
|
_timer.cancel();
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,4 +22,13 @@ class User {
|
||||||
userProfile: profile,
|
userProfile: profile,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map toMap() {
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"username": username,
|
||||||
|
"registrationDate": registrationDate.toIso8601String(),
|
||||||
|
"profile": userProfile?.toMap(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,21 +1,15 @@
|
||||||
import 'package:contacts_plus/config.dart';
|
|
||||||
|
|
||||||
class UserProfile {
|
class UserProfile {
|
||||||
final String iconUrl;
|
final String iconUrl;
|
||||||
|
|
||||||
UserProfile({required this.iconUrl});
|
UserProfile({required this.iconUrl});
|
||||||
|
|
||||||
factory UserProfile.fromMap(Map map) {
|
factory UserProfile.fromMap(Map map) {
|
||||||
return UserProfile(iconUrl: map["iconUrl"]);
|
return UserProfile(iconUrl: map["iconUrl"] ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri get httpIconUri {
|
Map toMap() {
|
||||||
final fullUri = iconUrl.replaceFirst("neosdb:///", Config.neosCdnUrl);
|
return {
|
||||||
final lastPeriodIndex = fullUri.lastIndexOf(".");
|
"iconUrl": iconUrl,
|
||||||
if (lastPeriodIndex != -1 && fullUri.length - lastPeriodIndex < 8) {
|
};
|
||||||
// I feel like 8 is a good maximum for file extension length? Can neosdb Uris even come without file extensions?
|
|
||||||
return Uri.parse(fullUri.substring(0, lastPeriodIndex));
|
|
||||||
}
|
|
||||||
return Uri.parse(fullUri);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -26,10 +26,10 @@ class _ExpandingInputFabState extends State<ExpandingInputFab> {
|
||||||
children: [
|
children: [
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
alignment: Alignment.bottomRight,
|
alignment: Alignment.bottomRight,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 200),
|
||||||
reverseDuration: const Duration(milliseconds: 300),
|
reverseDuration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeInOut,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:contacts_plus/aux.dart';
|
||||||
import 'package:contacts_plus/models/friend.dart';
|
import 'package:contacts_plus/models/friend.dart';
|
||||||
|
import 'package:contacts_plus/widgets/generic_avatar.dart';
|
||||||
import 'package:contacts_plus/widgets/messages.dart';
|
import 'package:contacts_plus/widgets/messages.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -10,22 +12,9 @@ class FriendListTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final imageUri = Aux.neosDbToHttp(friend.userProfile.iconUrl);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CachedNetworkImage(
|
leading: GenericAvatar(imageUri: imageUri,),
|
||||||
imageBuilder: (context, imageProvider) {
|
|
||||||
return CircleAvatar(
|
|
||||||
foregroundImage: imageProvider,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
imageUrl: friend.userProfile.httpIconUri.toString(),
|
|
||||||
placeholder: (context, url) {
|
|
||||||
return const CircleAvatar(backgroundColor: Colors.white54,);
|
|
||||||
},
|
|
||||||
errorWidget: (context, error, what) => const CircleAvatar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
child: Icon(Icons.person),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(friend.username),
|
title: Text(friend.username),
|
||||||
subtitle: Text(friend.userStatus.onlineStatus.name),
|
subtitle: Text(friend.userStatus.onlineStatus.name),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
@ -33,5 +22,4 @@ class FriendListTile extends StatelessWidget {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
33
lib/widgets/generic_avatar.dart
Normal file
33
lib/widgets/generic_avatar.dart
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class GenericAvatar extends StatelessWidget {
|
||||||
|
const GenericAvatar({this.imageUri="", super.key});
|
||||||
|
|
||||||
|
final String imageUri;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return imageUri.isEmpty ? const CircleAvatar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: Icon(Icons.person),
|
||||||
|
) : CachedNetworkImage(
|
||||||
|
imageBuilder: (context, imageProvider) {
|
||||||
|
return CircleAvatar(
|
||||||
|
foregroundImage: imageProvider,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
imageUrl: imageUri,
|
||||||
|
placeholder: (context, url) {
|
||||||
|
return const CircleAvatar(backgroundColor: Colors.white54,);
|
||||||
|
},
|
||||||
|
errorWidget: (context, error, what) => const CircleAvatar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: Icon(Icons.person),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
Future<List>? _friendFuture;
|
Future<List>? _friendFuture;
|
||||||
ClientHolder? _clientHolder;
|
ClientHolder? _clientHolder;
|
||||||
Timer? _debouncer;
|
Timer? _debouncer;
|
||||||
|
bool _searchIsLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
@ -40,6 +41,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshFriendsList() {
|
void _refreshFriendsList() {
|
||||||
|
_searchIsLoading = true;
|
||||||
_listFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
|
_listFuture = FriendApi.getFriendsList(_clientHolder!.client).then((Iterable<Friend> value) =>
|
||||||
value.toList()
|
value.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
|
@ -54,7 +56,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
).whenComplete(() => setState((){ _searchIsLoading = false; }));
|
||||||
_friendFuture = _listFuture;
|
_friendFuture = _listFuture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +66,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
return a.username.length.compareTo(b.username.length);
|
return a.username.length.compareTo(b.username.length);
|
||||||
},)
|
},)
|
||||||
);
|
).whenComplete(() => setState((){ _searchIsLoading = false; }));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _restoreFriendsList() {
|
void _restoreFriendsList() {
|
||||||
|
@ -102,6 +104,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (snapshot.hasError) {
|
} else if (snapshot.hasError) {
|
||||||
|
FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(64),
|
padding: const EdgeInsets.all(64),
|
||||||
|
@ -116,7 +119,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return const LinearProgressIndicator();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
@ -128,15 +131,22 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
if (_debouncer?.isActive ?? false) _debouncer?.cancel();
|
if (_debouncer?.isActive ?? false) _debouncer?.cancel();
|
||||||
if (text.isEmpty) {
|
if (text.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_searchIsLoading = false;
|
||||||
_restoreFriendsList();
|
_restoreFriendsList();
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setState(() {
|
||||||
|
_searchIsLoading = true;
|
||||||
|
});
|
||||||
_debouncer = Timer(const Duration(milliseconds: 500), () {
|
_debouncer = Timer(const Duration(milliseconds: 500), () {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (text.isNotEmpty) {
|
if(text.isNotEmpty) {
|
||||||
_searchForUsers(text);
|
_searchForUsers(text);
|
||||||
}
|
} else {
|
||||||
});
|
_searchIsLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onExpansionChanged: (expanded) {
|
onExpansionChanged: (expanded) {
|
||||||
|
@ -149,6 +159,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_searchIsLoading) const Align(alignment: Alignment.topCenter, child: LinearProgressIndicator(),)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:contacts_plus/apis/message_api.dart';
|
import 'package:contacts_plus/apis/message_api.dart';
|
||||||
|
import 'package:contacts_plus/aux.dart';
|
||||||
import 'package:contacts_plus/main.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:contacts_plus/widgets/generic_avatar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class Messages extends StatefulWidget {
|
class Messages extends StatefulWidget {
|
||||||
|
@ -12,36 +16,111 @@ class Messages extends StatefulWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => _MessagesState();
|
State<StatefulWidget> createState() => _MessagesState();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MessagesState extends State<Messages> {
|
class _MessagesState extends State<Messages> {
|
||||||
|
static const double headerItemSize = 300.0;
|
||||||
Future<Iterable<Message>>? _messagesFuture;
|
Future<Iterable<Message>>? _messagesFuture;
|
||||||
final TextEditingController _messageTextController = TextEditingController();
|
final TextEditingController _messageTextController = TextEditingController();
|
||||||
ClientHolder? _clientHolder;
|
ClientHolder? _clientHolder;
|
||||||
|
MessageCacheHolder? _cacheHolder;
|
||||||
|
|
||||||
|
bool _headerExpanded = false;
|
||||||
bool _isSendable = false;
|
bool _isSendable = false;
|
||||||
|
|
||||||
|
double get _headerHeight => _headerExpanded ? headerItemSize : 0;
|
||||||
|
double get _chevronTurns => _headerExpanded ? -1/4 : 1/4;
|
||||||
|
|
||||||
void _refreshMessages() {
|
void _refreshMessages() {
|
||||||
_messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id)..then((value) => value.toList());
|
final cache = _cacheHolder?.getCache(widget.friend.id);
|
||||||
|
if (cache?.isValid ?? false) {
|
||||||
|
_messagesFuture = Future(() => cache!.messages);
|
||||||
|
} else {
|
||||||
|
_messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id)
|
||||||
|
..then((value) {
|
||||||
|
final list = value.toList();
|
||||||
|
_cacheHolder?.setCache(widget.friend.id, list);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
final clientHolder = ClientHolder.of(context);
|
final clientHolder = ClientHolder.of(context);
|
||||||
|
bool dirty = false;
|
||||||
if (_clientHolder != clientHolder) {
|
if (_clientHolder != clientHolder) {
|
||||||
_clientHolder = clientHolder;
|
_clientHolder = clientHolder;
|
||||||
_refreshMessages();
|
dirty = true;
|
||||||
}
|
}
|
||||||
|
final cacheHolder = MessageCacheHolder.of(context);
|
||||||
|
if (_cacheHolder != cacheHolder) {
|
||||||
|
_cacheHolder = cacheHolder;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if (dirty) _refreshMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final apiClient = ClientHolder.of(context).client;
|
final apiClient = ClientHolder.of(context).client;
|
||||||
|
var sessions = widget.friend.userStatus.activeSessions;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.friend.username),
|
title: Text(widget.friend.username),
|
||||||
|
actions: [
|
||||||
|
if(sessions.isNotEmpty) AnimatedRotation(
|
||||||
|
turns: _chevronTurns,
|
||||||
|
curve: Curves.easeOutCirc,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_headerExpanded = !_headerExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
scrolledUnderElevation: 0.0,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
bottom: sessions.isEmpty ? null : PreferredSize(
|
||||||
|
preferredSize: Size.fromHeight(_headerHeight),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
height: _headerHeight,
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
child: Column(
|
||||||
|
children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(e.name),
|
||||||
|
Text("${e.sessionUsers.length} users active"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (sessions.length > 1) TextButton(onPressed: (){
|
||||||
|
setState(() {
|
||||||
|
_headerExpanded = !_headerExpanded;
|
||||||
|
});
|
||||||
|
}, child: Text("+${sessions.length-1}"),)
|
||||||
|
],
|
||||||
|
)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: FutureBuilder(
|
body: FutureBuilder(
|
||||||
future: _messagesFuture,
|
future: _messagesFuture,
|
||||||
|
@ -59,19 +138,34 @@ class _MessagesState extends State<Messages> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (snapshot.hasError) {
|
} else if (snapshot.hasError) {
|
||||||
return Column(
|
return Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 64, vertical: 128),
|
||||||
Text("Failed to load messages:\n${snapshot.error}"),
|
child: Center(
|
||||||
TextButton.icon(
|
child: Column(
|
||||||
onPressed: () {
|
mainAxisSize: MainAxisSize.max,
|
||||||
setState(() {
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
_refreshMessages();
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
});
|
children: [
|
||||||
},
|
Text("Failed to load messages:", style: Theme.of(context).textTheme.titleMedium,),
|
||||||
icon: const Icon(Icons.refresh),
|
const SizedBox(height: 16,),
|
||||||
label: const Text("Retry"),
|
Text("${snapshot.error}"),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_refreshMessages();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text("Retry"),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return const LinearProgressIndicator();
|
return const LinearProgressIndicator();
|
||||||
|
@ -79,11 +173,12 @@ class _MessagesState extends State<Messages> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomAppBar(
|
bottomNavigationBar: BottomAppBar(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageTextController,
|
controller: _messageTextController,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
|
@ -102,17 +197,16 @@ class _MessagesState extends State<Messages> {
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
hintText: "Send a message to ${widget.friend.username}...",
|
hintText: "Send a message to ${widget.friend.username}...",
|
||||||
hintStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: Colors.white54),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24)
|
||||||
),
|
)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
padding: const EdgeInsets.only(left: 8, right: 4.0),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
splashRadius: 24,
|
splashRadius: 24,
|
||||||
onPressed: _isSendable ? () async {
|
onPressed: _isSendable ? () async {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:contacts_plus/models/user.dart';
|
import 'package:contacts_plus/models/user.dart';
|
||||||
|
import 'package:contacts_plus/widgets/generic_avatar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
@ -20,21 +19,7 @@ class _UserListTileState extends State<UserListTile> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CachedNetworkImage(
|
leading: GenericAvatar(imageUri: widget.user.userProfile?.iconUrl ?? "",),
|
||||||
imageBuilder: (context, imageProvider) {
|
|
||||||
return CircleAvatar(
|
|
||||||
foregroundImage: imageProvider,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
imageUrl: widget.user.userProfile?.httpIconUri.toString() ?? "",
|
|
||||||
placeholder: (context, url) {
|
|
||||||
return const CircleAvatar(backgroundColor: Colors.white54,);
|
|
||||||
},
|
|
||||||
errorWidget: (context, error, what) => const CircleAvatar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
child: Icon(Icons.person),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(widget.user.username),
|
title: Text(widget.user.username),
|
||||||
subtitle: Text(_regDateFormat.format(widget.user.registrationDate)),
|
subtitle: Text(_regDateFormat.format(widget.user.registrationDate)),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
|
@ -43,6 +28,7 @@ class _UserListTileState extends State<UserListTile> {
|
||||||
_localAdded = !_localAdded;
|
_localAdded = !_localAdded;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
splashRadius: 24,
|
||||||
icon: _localAdded ? const Icon(Icons.person_remove_alt_1) : const Icon(Icons.person_add_alt_1),
|
icon: _localAdded ? const Icon(Icons.person_remove_alt_1) : const Icon(Icons.person_add_alt_1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue