Add session list and add support for formatted strings
This commit is contained in:
parent
0a73acc35c
commit
aabb23f326
16 changed files with 905 additions and 278 deletions
23
lib/apis/session_api.dart
Normal file
23
lib/apis/session_api.dart
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||||
|
import 'package:contacts_plus_plus/models/session.dart';
|
||||||
|
|
||||||
|
class SessionApi {
|
||||||
|
static Future<List<Session>> getSessions(ApiClient client, {DateTime? updatedSince, bool includeEnded = false,
|
||||||
|
String name = "", String hostName = "", String hostId = "", int minActiveUsers = 0, bool includeEmptyHeadless = true,
|
||||||
|
}) async {
|
||||||
|
final query = "?includeEnded=$includeEnded"
|
||||||
|
"&includeEmptyHeadless=$includeEmptyHeadless"
|
||||||
|
"&minActiveUsers=$minActiveUsers"
|
||||||
|
"${updatedSince == null ? "" : "&updatedSince=${updatedSince.toIso8601String()}"}"
|
||||||
|
"${name.isEmpty ? "" : "&name=$name"}"
|
||||||
|
"${hostName.isEmpty ? "" : "&hostName=$hostName"}"
|
||||||
|
"${hostId.isEmpty ? "" : "&hostId=$hostId"}";
|
||||||
|
final response = await client.get("/sessions$query");
|
||||||
|
ApiClient.checkResponse(response);
|
||||||
|
final body = jsonDecode(response.body) as List;
|
||||||
|
return body.map((e) => Session.fromMap(e)).toList();
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:contacts_plus_plus/config.dart';
|
import 'package:contacts_plus_plus/config.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:html/parser.dart' as htmlparser;
|
import 'package:html/parser.dart' as htmlparser;
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
@ -44,15 +45,41 @@ extension Unique<E, Id> on List<E> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Strip on String {
|
extension StringX on String {
|
||||||
|
|
||||||
String stripHtml() {
|
String stripHtml() {
|
||||||
final document = htmlparser.parse(this);
|
final document = htmlparser.parse(this);
|
||||||
return htmlparser.parse(document.body?.text).documentElement?.text ?? "";
|
return htmlparser
|
||||||
|
.parse(document.body?.text)
|
||||||
|
.documentElement
|
||||||
|
?.text ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// This won't be accurate since userIds can't contain certain characters that usernames can
|
// This won't be accurate since userIds can't contain certain characters that usernames can
|
||||||
// but it's fine for just having a name to display
|
// but it's fine for just having a name to display
|
||||||
String stripUid() => startsWith("U-") ? substring(2) : this;
|
String stripUid() => startsWith("U-") ? substring(2) : this;
|
||||||
|
|
||||||
|
String get overflow =>
|
||||||
|
Characters(this)
|
||||||
|
.replaceAll(Characters(''), Characters('\u{200B}'))
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
bool looseMatch(String other) {
|
||||||
|
if (other.isEmpty) return true;
|
||||||
|
var index = 0;
|
||||||
|
for (final needleChar in other.characters) {
|
||||||
|
if (index >= characters.length) return false;
|
||||||
|
for (; index < characters.length; index++) {
|
||||||
|
if (needleChar.toLowerCase() == characters.elementAt(index).toLowerCase()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index < characters.length) {
|
||||||
|
return true;
|
||||||
|
} else if (characters.last.toLowerCase() == other.characters.last.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Format on Duration {
|
extension Format on Duration {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:contacts_plus_plus/models/authentication_data.dart';
|
import 'package:contacts_plus_plus/models/authentication_data.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
|
@ -17,9 +18,10 @@ class ApiClient {
|
||||||
static const String tokenKey = "token";
|
static const String tokenKey = "token";
|
||||||
static const String passwordKey = "password";
|
static const String passwordKey = "password";
|
||||||
|
|
||||||
const ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
|
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
|
||||||
|
|
||||||
final AuthenticationData _authenticationData;
|
final AuthenticationData _authenticationData;
|
||||||
|
final Logger _logger = Logger("ApiClient");
|
||||||
|
|
||||||
AuthenticationData get authenticationData => _authenticationData;
|
AuthenticationData get authenticationData => _authenticationData;
|
||||||
String get userId => _authenticationData.userId;
|
String get userId => _authenticationData.userId;
|
||||||
|
@ -137,6 +139,7 @@ class ApiClient {
|
||||||
Future<http.Response> get(String path, {Map<String, String>? headers}) {
|
Future<http.Response> get(String path, {Map<String, String>? headers}) {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers.addAll(authorizationHeader);
|
headers.addAll(authorizationHeader);
|
||||||
|
_logger.info("GET: $path");
|
||||||
return http.get(buildFullUri(path), headers: headers);
|
return http.get(buildFullUri(path), headers: headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,6 +147,7 @@ class ApiClient {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
headers.addAll(authorizationHeader);
|
headers.addAll(authorizationHeader);
|
||||||
|
_logger.info("PST: $path");
|
||||||
return http.post(buildFullUri(path), headers: headers, body: body);
|
return http.post(buildFullUri(path), headers: headers, body: body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,18 +155,21 @@ class ApiClient {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
headers.addAll(authorizationHeader);
|
headers.addAll(authorizationHeader);
|
||||||
|
_logger.info("PUT: $path");
|
||||||
return http.put(buildFullUri(path), headers: headers, body: body);
|
return http.put(buildFullUri(path), headers: headers, body: body);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<http.Response> delete(String path, {Map<String, String>? headers}) {
|
Future<http.Response> delete(String path, {Map<String, String>? headers}) {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers.addAll(authorizationHeader);
|
headers.addAll(authorizationHeader);
|
||||||
|
_logger.info("DEL: $path");
|
||||||
return http.delete(buildFullUri(path), headers: headers);
|
return http.delete(buildFullUri(path), headers: headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<http.Response> patch(String path, {Map<String, String>? headers}) {
|
Future<http.Response> patch(String path, {Map<String, String>? headers}) {
|
||||||
headers ??= {};
|
headers ??= {};
|
||||||
headers.addAll(authorizationHeader);
|
headers.addAll(authorizationHeader);
|
||||||
|
_logger.info("PAT: $path");
|
||||||
return http.patch(buildFullUri(path), headers: headers);
|
return http.patch(buildFullUri(path), headers: headers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,12 +12,14 @@ import 'package:contacts_plus_plus/models/friend.dart';
|
||||||
import 'package:contacts_plus_plus/models/settings.dart';
|
import 'package:contacts_plus_plus/models/settings.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||||
import 'package:contacts_plus_plus/config.dart';
|
import 'package:contacts_plus_plus/config.dart';
|
||||||
import 'package:contacts_plus_plus/models/message.dart';
|
import 'package:contacts_plus_plus/models/message.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
enum EventType {
|
enum EventType {
|
||||||
|
@ -54,10 +56,10 @@ class MessagingClient extends ChangeNotifier {
|
||||||
static const String unreadCheckTaskName = "periodic-unread-check";
|
static const String unreadCheckTaskName = "periodic-unread-check";
|
||||||
static const String _storageNotifiedUnreadsKey = "notfiedUnreads";
|
static const String _storageNotifiedUnreadsKey = "notfiedUnreads";
|
||||||
static const String _storageLastUpdateKey = "lastUnreadCheck";
|
static const String _storageLastUpdateKey = "lastUnreadCheck";
|
||||||
|
static const String _hiveKey = "mClient";
|
||||||
|
static const String _storedFriendsKey = "friends";
|
||||||
static const FlutterSecureStorage _storage = FlutterSecureStorage();
|
static const FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||||
|
|
||||||
static const Duration _autoRefreshDuration = Duration(seconds: 90);
|
static const Duration _autoRefreshDuration = Duration(seconds: 90);
|
||||||
static const Duration _refreshTimeoutDuration = Duration(seconds: 30);
|
|
||||||
|
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
final Map<String, Friend> _friendsCache = {};
|
final Map<String, Friend> _friendsCache = {};
|
||||||
|
@ -86,16 +88,27 @@ class MessagingClient extends ChangeNotifier {
|
||||||
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient,
|
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient,
|
||||||
required SettingsClient settingsClient})
|
required SettingsClient settingsClient})
|
||||||
: _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient {
|
: _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient {
|
||||||
refreshFriendsListWithErrorHandler();
|
initBox().whenComplete(() async {
|
||||||
|
try {
|
||||||
|
await _restoreFriendsList();
|
||||||
|
try {
|
||||||
|
await refreshFriendsList();
|
||||||
|
} catch (_) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
refreshFriendsListWithErrorHandler();
|
||||||
|
}
|
||||||
|
});
|
||||||
startWebsocket();
|
startWebsocket();
|
||||||
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
|
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
|
||||||
// We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this
|
// We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this
|
||||||
// but I don't feel like implementing that right now.
|
// but I don't feel like implementing that right now.
|
||||||
UserApi.setStatus(apiClient, status: await UserApi.getUserStatus(apiClient, userId: apiClient.userId));
|
UserApi.setStatus(apiClient, status: await UserApi.getUserStatus(apiClient, userId: apiClient.userId));
|
||||||
});
|
});
|
||||||
_settingsClient.addListener(onSettingsChanged);
|
//_settingsClient.addListener(onSettingsChanged);
|
||||||
if (!_settingsClient.currentSettings.notificationsDenied.valueOrDefault) {
|
if (!_settingsClient.currentSettings.notificationsDenied.valueOrDefault) {
|
||||||
registerNotificationTask();
|
//registerNotificationTask();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,6 +136,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
|
|
||||||
static Future<void> backgroundCheckUnreads(Map<String, dynamic>? inputData) async {
|
static Future<void> backgroundCheckUnreads(Map<String, dynamic>? inputData) async {
|
||||||
if (inputData == null) throw "Unauthenticated";
|
if (inputData == null) throw "Unauthenticated";
|
||||||
|
return;
|
||||||
final auth = AuthenticationData.fromMap(inputData);
|
final auth = AuthenticationData.fromMap(inputData);
|
||||||
const storage = FlutterSecureStorage();
|
const storage = FlutterSecureStorage();
|
||||||
final lastCheckData = await storage.read(key: _storageLastUpdateKey);
|
final lastCheckData = await storage.read(key: _storageLastUpdateKey);
|
||||||
|
@ -178,7 +192,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshFriendsListWithErrorHandler () async {
|
void refreshFriendsListWithErrorHandler() async {
|
||||||
try {
|
try {
|
||||||
await refreshFriendsList();
|
await refreshFriendsList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -187,9 +201,33 @@ class MessagingClient extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshFriendsList() async {
|
Future<void> initBox() async {
|
||||||
if (_refreshTimeout?.isActive == true) return;
|
try {
|
||||||
|
final path = await getTemporaryDirectory();
|
||||||
|
Hive.init(path.path);
|
||||||
|
await Hive.openBox(_hiveKey, path: path.path);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreFriendsList() async {
|
||||||
|
if (!Hive.isBoxOpen(_hiveKey)) throw "Failed to open box";
|
||||||
|
final mStorage = Hive.box(_hiveKey);
|
||||||
|
final storedFriends = await mStorage.get(_storedFriendsKey) as List?;
|
||||||
|
if (storedFriends == null) throw "No cached friends list";
|
||||||
|
_friendsCache.clear();
|
||||||
|
_sortedFriendsCache.clear();
|
||||||
|
|
||||||
|
for (final storedFriend in storedFriends) {
|
||||||
|
final friend = Friend.fromMap(storedFriend);
|
||||||
|
_friendsCache[friend.id] = friend;
|
||||||
|
_sortedFriendsCache.add(friend);
|
||||||
|
}
|
||||||
|
_sortFriendsCache();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> refreshFriendsList() async {
|
||||||
_autoRefresh?.cancel();
|
_autoRefresh?.cancel();
|
||||||
_autoRefresh = Timer(_autoRefreshDuration, () async {
|
_autoRefresh = Timer(_autoRefreshDuration, () async {
|
||||||
try {
|
try {
|
||||||
|
@ -199,23 +237,29 @@ class MessagingClient extends ChangeNotifier {
|
||||||
// just keep showing the old state until refreshing succeeds.
|
// just keep showing the old state until refreshing succeeds.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_refreshTimeout?.cancel();
|
final now = DateTime.now();
|
||||||
_refreshTimeout = Timer(_refreshTimeoutDuration, () {});
|
final lastUpdate = await _storage.read(key: _storageLastUpdateKey);
|
||||||
|
if (lastUpdate != null && now.difference(DateTime.parse(lastUpdate)) < _autoRefreshDuration) throw "You are being rate limited.";
|
||||||
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
|
|
||||||
updateAllUnreads(unreadMessages.toList());
|
|
||||||
|
|
||||||
final friends = await FriendApi.getFriendsList(_apiClient);
|
final friends = await FriendApi.getFriendsList(_apiClient);
|
||||||
|
final List<Map> storableFriends = [];
|
||||||
_friendsCache.clear();
|
_friendsCache.clear();
|
||||||
for (final friend in friends) {
|
for (final friend in friends) {
|
||||||
_friendsCache[friend.id] = friend;
|
_friendsCache[friend.id] = friend;
|
||||||
|
storableFriends.add(friend.toMap(shallow: true));
|
||||||
}
|
}
|
||||||
_sortedFriendsCache.clear();
|
_sortedFriendsCache.clear();
|
||||||
_sortedFriendsCache.addAll(friends);
|
_sortedFriendsCache.addAll(friends);
|
||||||
_sortFriendsCache();
|
_sortFriendsCache();
|
||||||
_initStatus = "";
|
_initStatus = "";
|
||||||
await _storage.write(key: _storageLastUpdateKey, value: DateTime.now().toIso8601String());
|
await _storage.write(key: _storageLastUpdateKey, value: now.toIso8601String());
|
||||||
|
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
|
||||||
|
updateAllUnreads(unreadMessages.toList());
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
if (!Hive.isBoxOpen(_hiveKey)) return;
|
||||||
|
final mStorage = Hive.box(_hiveKey);
|
||||||
|
mStorage.put(_storedFriendsKey, storableFriends);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sortFriendsCache() {
|
void _sortFriendsCache() {
|
||||||
|
|
|
@ -6,14 +6,13 @@ import 'package:contacts_plus_plus/client_holder.dart';
|
||||||
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||||
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
||||||
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/friends/friends_list.dart';
|
import 'package:contacts_plus_plus/widgets/home.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/login_screen.dart';
|
import 'package:contacts_plus_plus/widgets/login_screen.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/update_notifier.dart';
|
import 'package:contacts_plus_plus/widgets/update_notifier.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
import 'package:flutter_phoenix/flutter_phoenix.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
import 'models/authentication_data.dart';
|
import 'models/authentication_data.dart';
|
||||||
|
|
||||||
|
@ -42,8 +41,8 @@ void main() async {
|
||||||
|
|
||||||
@pragma('vm:entry-point') // Mandatory if the App is obfuscated or using Flutter 3.1+
|
@pragma('vm:entry-point') // Mandatory if the App is obfuscated or using Flutter 3.1+
|
||||||
void callbackDispatcher() {
|
void callbackDispatcher() {
|
||||||
|
return;
|
||||||
Workmanager().executeTask((String task, Map<String, dynamic>? inputData) async {
|
Workmanager().executeTask((String task, Map<String, dynamic>? inputData) async {
|
||||||
return Future.value(true); //Disable background tasks for now
|
|
||||||
debugPrint("Native called background task: $task"); //simpleTask will be emitted here.
|
debugPrint("Native called background task: $task"); //simpleTask will be emitted here.
|
||||||
if (task == MessagingClient.unreadCheckTaskName) {
|
if (task == MessagingClient.unreadCheckTaskName) {
|
||||||
try {
|
try {
|
||||||
|
@ -134,29 +133,22 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
|
||||||
),
|
),
|
||||||
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
showUpdateDialogOnFirstBuild(context);
|
showUpdateDialogOnFirstBuild(context);
|
||||||
final clientHolder = ClientHolder.of(context);
|
if (_authData.isAuthenticated) {
|
||||||
return _authData.isAuthenticated ?
|
return const Home();
|
||||||
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
} else {
|
||||||
create: (context) =>
|
return LoginScreen(
|
||||||
MessagingClient(
|
onLoginSuccessful: (AuthenticationData authData) async {
|
||||||
apiClient: clientHolder.apiClient,
|
if (authData.isAuthenticated) {
|
||||||
notificationClient: clientHolder.notificationClient,
|
setState(() {
|
||||||
settingsClient: widget.settingsClient,
|
_authData = authData;
|
||||||
),
|
});
|
||||||
child: const FriendsList(),
|
}
|
||||||
) :
|
},
|
||||||
LoginScreen(
|
);
|
||||||
onLoginSuccessful: (AuthenticationData authData) async {
|
}
|
||||||
if (authData.isAuthenticated) {
|
}
|
||||||
setState(() {
|
|
||||||
_authData = authData;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -18,7 +18,7 @@ class Friend extends Comparable {
|
||||||
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"] ?? map["username"],
|
||||||
ownerId: map["ownerId"] ?? map["id"],
|
ownerId: map["ownerId"] ?? map["id"],
|
||||||
userStatus: UserStatus.fromMap(map["userStatus"]),
|
userStatus: UserStatus.fromMap(map["userStatus"]),
|
||||||
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
|
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
|
||||||
|
@ -140,7 +140,7 @@ class UserStatus {
|
||||||
|
|
||||||
Map toMap({bool shallow=false}) {
|
Map toMap({bool shallow=false}) {
|
||||||
return {
|
return {
|
||||||
"onlineStatus": onlineStatus.index,
|
"onlineStatus": onlineStatus.name,
|
||||||
"lastStatusChange": lastStatusChange.toIso8601String(),
|
"lastStatusChange": lastStatusChange.toIso8601String(),
|
||||||
"activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),),
|
"activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),),
|
||||||
"neosVersion": neosVersion,
|
"neosVersion": neosVersion,
|
||||||
|
|
212
lib/string_formatter.dart
Normal file
212
lib/string_formatter.dart
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
import 'package:color/color.dart' as cc;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FormatNode {
|
||||||
|
String? text;
|
||||||
|
final FormatData format;
|
||||||
|
final List<FormatNode> children;
|
||||||
|
|
||||||
|
FormatNode({this.text, required this.format, required this.children});
|
||||||
|
|
||||||
|
TextSpan toTextSpan({required TextStyle baseStyle}) {
|
||||||
|
return TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: format.isUnformatted ? baseStyle : format.style(baseStyle),
|
||||||
|
children: children.map((e) => e.toTextSpan(baseStyle: baseStyle)).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
|
||||||
|
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
|
||||||
|
final root = FormatNode(format: styles.first, children: []);
|
||||||
|
var current = root;
|
||||||
|
for (final style in styles.sublist(1)) {
|
||||||
|
final next = FormatNode(format: style, children: []);
|
||||||
|
current.children.add(next);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
current.text = text;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringFormatter {
|
||||||
|
static TextSpan? tryFormat(String text, {TextStyle? baseStyle}) {
|
||||||
|
try {
|
||||||
|
final content = StringFormatter.format(text, baseStyle: baseStyle);
|
||||||
|
if ((content.children?.isEmpty ?? true) && content.style == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextSpan format(String text, {TextStyle? baseStyle}) {
|
||||||
|
baseStyle ??= const TextStyle();
|
||||||
|
var tags = parseTags(text);
|
||||||
|
if (tags.isEmpty) return TextSpan(text: text, style: null, children: const []);
|
||||||
|
final root = FormatNode(
|
||||||
|
format: FormatData.unformatted(),
|
||||||
|
text: text.substring(0, tags.first.startIndex),
|
||||||
|
children: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final activeTags = <FormatData>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < tags.length; i++) {
|
||||||
|
final tag = tags[i];
|
||||||
|
final substr = text.substring(tag.endIndex, (i + 1 < tags.length) ? tags[i + 1].startIndex : null);
|
||||||
|
if (tag.format.isAdditive) {
|
||||||
|
activeTags.add(tag.format);
|
||||||
|
} else {
|
||||||
|
final idx = activeTags.lastIndexWhere((element) => element.name == tag.format.name);
|
||||||
|
if (idx != -1) {
|
||||||
|
activeTags.removeAt(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (substr.isNotEmpty) {
|
||||||
|
root.children.add(
|
||||||
|
FormatNode.buildFromStyles(activeTags, substr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root.toTextSpan(baseStyle: baseStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<FormatTag> parseTags(String text) {
|
||||||
|
final startMatches = RegExp(r"<(.+?)>").allMatches(text);
|
||||||
|
|
||||||
|
final spans = <FormatTag>[];
|
||||||
|
|
||||||
|
for (final startMatch in startMatches) {
|
||||||
|
final fullTag = startMatch.group(1);
|
||||||
|
if (fullTag == null) continue;
|
||||||
|
final tag = FormatData.parse(fullTag);
|
||||||
|
spans.add(
|
||||||
|
FormatTag(
|
||||||
|
startIndex: startMatch.start,
|
||||||
|
endIndex: startMatch.end,
|
||||||
|
format: tag,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormatTag {
|
||||||
|
final int startIndex;
|
||||||
|
final int endIndex;
|
||||||
|
final FormatData format;
|
||||||
|
|
||||||
|
const FormatTag({
|
||||||
|
required this.startIndex,
|
||||||
|
required this.endIndex,
|
||||||
|
required this.format,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormatAction {
|
||||||
|
final String Function(String input, String parameter)? transform;
|
||||||
|
final TextStyle Function(String? parameter, TextStyle baseStyle)? style;
|
||||||
|
|
||||||
|
FormatAction({this.transform, this.style});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormatData {
|
||||||
|
static Color? tryParseColor(String? text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
var color = cc.RgbColor.namedColors[text];
|
||||||
|
if (color != null) {
|
||||||
|
return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
color = cc.HexColor(text);
|
||||||
|
return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round());
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final Map<String, FormatAction> _richTextTags = {
|
||||||
|
"align": FormatAction(),
|
||||||
|
"alpha": FormatAction(style: (param, baseStyle) {
|
||||||
|
if (param == null || !param.startsWith("#")) return baseStyle;
|
||||||
|
final alpha = int.tryParse(param.substring(1), radix: 16);
|
||||||
|
if (alpha == null) return baseStyle;
|
||||||
|
return baseStyle.copyWith(color: baseStyle.color?.withAlpha(alpha));
|
||||||
|
}),
|
||||||
|
"color": FormatAction(style: (param, baseStyle) {
|
||||||
|
if (param == null) return baseStyle;
|
||||||
|
final color = tryParseColor(param);
|
||||||
|
if (color == null) return baseStyle;
|
||||||
|
return baseStyle.copyWith(color: color);
|
||||||
|
}),
|
||||||
|
"b": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
"br": FormatAction(transform: (text, param) => "\n$text"),
|
||||||
|
"i": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontStyle: FontStyle.italic)),
|
||||||
|
"cspace": FormatAction(),
|
||||||
|
"font": FormatAction(),
|
||||||
|
"indent": FormatAction(),
|
||||||
|
"line-height": FormatAction(),
|
||||||
|
"line-indent": FormatAction(),
|
||||||
|
"link": FormatAction(),
|
||||||
|
"lowercase": FormatAction(),
|
||||||
|
"uppercase": FormatAction(),
|
||||||
|
"smallcaps": FormatAction(),
|
||||||
|
"margin": FormatAction(),
|
||||||
|
"mark": FormatAction(style: (param, baseStyle) {
|
||||||
|
if (param == null) return baseStyle;
|
||||||
|
final color = tryParseColor(param);
|
||||||
|
if (color == null) return baseStyle;
|
||||||
|
return baseStyle.copyWith(backgroundColor: color);
|
||||||
|
}),
|
||||||
|
"mspace": FormatAction(),
|
||||||
|
"noparse": FormatAction(),
|
||||||
|
"nobr": FormatAction(),
|
||||||
|
"page": FormatAction(),
|
||||||
|
"pos": FormatAction(),
|
||||||
|
"size": FormatAction(),
|
||||||
|
"space": FormatAction(),
|
||||||
|
"sprite": FormatAction(),
|
||||||
|
"s": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.lineThrough)),
|
||||||
|
"u": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.underline)),
|
||||||
|
"style": FormatAction(),
|
||||||
|
"sub": FormatAction(),
|
||||||
|
"sup": FormatAction(),
|
||||||
|
"voffset": FormatAction(),
|
||||||
|
"width": FormatAction(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String parameter;
|
||||||
|
final bool isAdditive;
|
||||||
|
|
||||||
|
const FormatData({required this.name, required this.parameter, required this.isAdditive});
|
||||||
|
|
||||||
|
factory FormatData.parse(String text) {
|
||||||
|
if (text.contains("/")) return FormatData(name: text.replaceAll("/", ""), parameter: "", isAdditive: false);
|
||||||
|
final sepIdx = text.indexOf("=");
|
||||||
|
if (sepIdx == -1) {
|
||||||
|
return FormatData(name: text, parameter: "", isAdditive: true);
|
||||||
|
} else {
|
||||||
|
return FormatData(
|
||||||
|
name: text.substring(0, sepIdx).trim().toLowerCase(),
|
||||||
|
parameter: text.substring(sepIdx + 1, text.length).trim().toLowerCase(),
|
||||||
|
isAdditive: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factory FormatData.unformatted() => const FormatData(name: "", parameter: "", isAdditive: false);
|
||||||
|
|
||||||
|
bool get isUnformatted => name.isEmpty && parameter.isEmpty && !isAdditive;
|
||||||
|
|
||||||
|
bool get isValid => _richTextTags.containsKey(name);
|
||||||
|
|
||||||
|
String? apply(String? text) => text == null ? null : _richTextTags[name]?.transform?.call(text, parameter);
|
||||||
|
|
||||||
|
TextStyle style(TextStyle baseStyle) => _richTextTags[name]?.style?.call(parameter, baseStyle) ?? baseStyle;
|
||||||
|
}
|
|
@ -24,234 +24,20 @@ class MenuItemDefinition {
|
||||||
const MenuItemDefinition({required this.name, required this.icon, required this.onTap});
|
const MenuItemDefinition({required this.name, required this.icon, required this.onTap});
|
||||||
}
|
}
|
||||||
|
|
||||||
class FriendsList extends StatefulWidget {
|
class FriendsList extends StatefulWidget {
|
||||||
const FriendsList({super.key});
|
const FriendsList({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FriendsList> createState() => _FriendsListState();
|
State<FriendsList> createState() => _FriendsListState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FriendsListState extends State<FriendsList> {
|
class _FriendsListState extends State<FriendsList> with AutomaticKeepAliveClientMixin {
|
||||||
Future<PersonalProfile>? _userProfileFuture;
|
|
||||||
Future<UserStatus>? _userStatusFuture;
|
|
||||||
ClientHolder? _clientHolder;
|
|
||||||
String _searchFilter = "";
|
String _searchFilter = "";
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() async {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
final clientHolder = ClientHolder.of(context);
|
|
||||||
if (_clientHolder != clientHolder) {
|
|
||||||
_clientHolder = clientHolder;
|
|
||||||
final apiClient = _clientHolder!.apiClient;
|
|
||||||
_userProfileFuture = UserApi.getPersonalProfile(apiClient);
|
|
||||||
_refreshUserStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refreshUserStatus() {
|
|
||||||
final apiClient = _clientHolder!.apiClient;
|
|
||||||
_userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async {
|
|
||||||
if (value.onlineStatus == OnlineStatus.offline) {
|
|
||||||
final newStatus = value.copyWith(
|
|
||||||
onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus
|
|
||||||
.valueOrDefault]
|
|
||||||
);
|
|
||||||
await UserApi.setStatus(apiClient, status: newStatus);
|
|
||||||
return newStatus;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final clientHolder = ClientHolder.of(context);
|
super.build(context);
|
||||||
return Scaffold(
|
return Stack(
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text("Contacts++"),
|
|
||||||
actions: [
|
|
||||||
FutureBuilder(
|
|
||||||
future: _userStatusFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final userStatus = snapshot.data as UserStatus;
|
|
||||||
return PopupMenuButton<OnlineStatus>(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,),
|
|
||||||
),
|
|
||||||
Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onSelected: (OnlineStatus onlineStatus) async {
|
|
||||||
try {
|
|
||||||
final newStatus = userStatus.copyWith(onlineStatus: onlineStatus);
|
|
||||||
setState(() {
|
|
||||||
_userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now()));
|
|
||||||
});
|
|
||||||
final settingsClient = ClientHolder
|
|
||||||
.of(context)
|
|
||||||
.settingsClient;
|
|
||||||
await UserApi.setStatus(clientHolder.apiClient, status: newStatus);
|
|
||||||
await settingsClient.changeSettings(
|
|
||||||
settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
|
|
||||||
} catch (e, s) {
|
|
||||||
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(
|
|
||||||
"Failed to set online-status.")));
|
|
||||||
setState(() {
|
|
||||||
_userStatusFuture = Future.value(userStatus);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (BuildContext context) =>
|
|
||||||
OnlineStatus.values.where((element) =>
|
|
||||||
element == OnlineStatus.online
|
|
||||||
|| element == OnlineStatus.invisible).map((item) =>
|
|
||||||
PopupMenuItem<OnlineStatus>(
|
|
||||||
value: item,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.circle, size: 16, color: item.color,),
|
|
||||||
const SizedBox(width: 8,),
|
|
||||||
Text(toBeginningOfSentenceCase(item.name)!),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toList());
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
return TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Theme
|
|
||||||
.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurface,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2)
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_userStatusFuture = null;
|
|
||||||
});
|
|
||||||
setState(() {
|
|
||||||
_userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient
|
|
||||||
.userId);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.warning),
|
|
||||||
label: const Text("Retry"),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
disabledForegroundColor: Theme
|
|
||||||
.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurface,
|
|
||||||
),
|
|
||||||
onPressed: null,
|
|
||||||
icon: Container(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
margin: const EdgeInsets.only(right: 4),
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Theme
|
|
||||||
.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
label: const Text("Loading"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4, right: 4),
|
|
||||||
child: PopupMenuButton<MenuItemDefinition>(
|
|
||||||
icon: const Icon(Icons.more_vert),
|
|
||||||
onSelected: (MenuItemDefinition itemDef) async {
|
|
||||||
await itemDef.onTap();
|
|
||||||
},
|
|
||||||
itemBuilder: (BuildContext context) =>
|
|
||||||
[
|
|
||||||
MenuItemDefinition(
|
|
||||||
name: "Settings",
|
|
||||||
icon: Icons.settings,
|
|
||||||
onTap: () async {
|
|
||||||
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MenuItemDefinition(
|
|
||||||
name: "Find Users",
|
|
||||||
icon: Icons.person_add,
|
|
||||||
onTap: () async {
|
|
||||||
final mClient = Provider.of<MessagingClient>(context, listen: false);
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
ChangeNotifierProvider<MessagingClient>.value(
|
|
||||||
value: mClient,
|
|
||||||
child: const UserSearch(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MenuItemDefinition(
|
|
||||||
name: "My Profile",
|
|
||||||
icon: Icons.person,
|
|
||||||
onTap: () async {
|
|
||||||
await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return FutureBuilder(
|
|
||||||
future: _userProfileFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final profile = snapshot.data as PersonalProfile;
|
|
||||||
return MyProfileDialog(profile: profile);
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
return DefaultErrorWidget(
|
|
||||||
title: "Failed to load personal profile.",
|
|
||||||
onRetry: () {
|
|
||||||
setState(() {
|
|
||||||
_userProfileFuture = UserApi.getPersonalProfile(ClientHolder
|
|
||||||
.of(context)
|
|
||||||
.apiClient);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const Center(child: CircularProgressIndicator(),);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
].map((item) =>
|
|
||||||
PopupMenuItem<MenuItemDefinition>(
|
|
||||||
value: item,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(item.name),
|
|
||||||
Icon(item.icon),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toList(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
children: [
|
children: [
|
||||||
Consumer<MessagingClient>(
|
Consumer<MessagingClient>(
|
||||||
|
@ -311,7 +97,237 @@ class _FriendsListState extends State<FriendsList> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// TODO: implement wantKeepAlive
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FriendsListAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||||
|
const FriendsListAppBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _FriendsListAppBarState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FriendsListAppBarState extends State<FriendsListAppBar> {
|
||||||
|
Future<UserStatus>? _userStatusFuture;
|
||||||
|
Future<PersonalProfile>? _userProfileFuture;
|
||||||
|
ClientHolder? _clientHolder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() async {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final clientHolder = ClientHolder.of(context);
|
||||||
|
if (_clientHolder != clientHolder) {
|
||||||
|
_clientHolder = clientHolder;
|
||||||
|
final apiClient = _clientHolder!.apiClient;
|
||||||
|
_userProfileFuture = UserApi.getPersonalProfile(apiClient);
|
||||||
|
_refreshUserStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshUserStatus() {
|
||||||
|
final apiClient = _clientHolder!.apiClient;
|
||||||
|
_userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async {
|
||||||
|
if (value.onlineStatus == OnlineStatus.offline) {
|
||||||
|
final newStatus = value.copyWith(
|
||||||
|
onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus
|
||||||
|
.valueOrDefault]
|
||||||
|
);
|
||||||
|
await UserApi.setStatus(apiClient, status: newStatus);
|
||||||
|
return newStatus;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final clientHolder = ClientHolder.of(context);
|
||||||
|
return AppBar(
|
||||||
|
title: const Text("Contacts++"),
|
||||||
|
actions: [
|
||||||
|
FutureBuilder(
|
||||||
|
future: _userStatusFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final userStatus = snapshot.data as UserStatus;
|
||||||
|
return PopupMenuButton<OnlineStatus>(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,),
|
||||||
|
),
|
||||||
|
Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onSelected: (OnlineStatus onlineStatus) async {
|
||||||
|
try {
|
||||||
|
final newStatus = userStatus.copyWith(onlineStatus: onlineStatus);
|
||||||
|
setState(() {
|
||||||
|
_userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now()));
|
||||||
|
});
|
||||||
|
final settingsClient = clientHolder.settingsClient;
|
||||||
|
await UserApi.setStatus(clientHolder.apiClient, status: newStatus);
|
||||||
|
await settingsClient.changeSettings(
|
||||||
|
settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
|
||||||
|
} catch (e, s) {
|
||||||
|
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(
|
||||||
|
"Failed to set online-status.")));
|
||||||
|
setState(() {
|
||||||
|
_userStatusFuture = Future.value(userStatus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (BuildContext context) =>
|
||||||
|
OnlineStatus.values.where((element) =>
|
||||||
|
element == OnlineStatus.online
|
||||||
|
|| element == OnlineStatus.invisible).map((item) =>
|
||||||
|
PopupMenuItem<OnlineStatus>(
|
||||||
|
value: item,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 16, color: item.color,),
|
||||||
|
const SizedBox(width: 8,),
|
||||||
|
Text(toBeginningOfSentenceCase(item.name)!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toList());
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme
|
||||||
|
.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2)
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_userStatusFuture = null;
|
||||||
|
});
|
||||||
|
setState(() {
|
||||||
|
_userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient
|
||||||
|
.userId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.warning),
|
||||||
|
label: const Text("Retry"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
disabledForegroundColor: Theme
|
||||||
|
.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface,
|
||||||
|
),
|
||||||
|
onPressed: null,
|
||||||
|
icon: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
margin: const EdgeInsets.only(right: 4),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Theme
|
||||||
|
.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
label: const Text("Loading"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 4),
|
||||||
|
child: PopupMenuButton<MenuItemDefinition>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (MenuItemDefinition itemDef) async {
|
||||||
|
await itemDef.onTap();
|
||||||
|
},
|
||||||
|
itemBuilder: (BuildContext context) =>
|
||||||
|
[
|
||||||
|
MenuItemDefinition(
|
||||||
|
name: "Settings",
|
||||||
|
icon: Icons.settings,
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItemDefinition(
|
||||||
|
name: "Find Users",
|
||||||
|
icon: Icons.person_add,
|
||||||
|
onTap: () async {
|
||||||
|
final mClient = Provider.of<MessagingClient>(context, listen: false);
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
ChangeNotifierProvider<MessagingClient>.value(
|
||||||
|
value: mClient,
|
||||||
|
child: const UserSearch(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItemDefinition(
|
||||||
|
name: "My Profile",
|
||||||
|
icon: Icons.person,
|
||||||
|
onTap: () async {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return FutureBuilder(
|
||||||
|
future: _userProfileFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final profile = snapshot.data as PersonalProfile;
|
||||||
|
return MyProfileDialog(profile: profile);
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return DefaultErrorWidget(
|
||||||
|
title: "Failed to load personal profile.",
|
||||||
|
onRetry: () {
|
||||||
|
setState(() {
|
||||||
|
_userProfileFuture = UserApi.getPersonalProfile(clientHolder.apiClient);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const Center(child: CircularProgressIndicator(),);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
].map((item) =>
|
||||||
|
PopupMenuItem<MenuItemDefinition>(
|
||||||
|
value: item,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(item.name),
|
||||||
|
Icon(item.icon),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
77
lib/widgets/home.dart
Normal file
77
lib/widgets/home.dart
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
|
||||||
|
import 'package:contacts_plus_plus/client_holder.dart';
|
||||||
|
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||||
|
import 'package:contacts_plus_plus/widgets/friends/friends_list.dart';
|
||||||
|
import 'package:contacts_plus_plus/widgets/sessions/sessions_list.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class Home extends StatefulWidget {
|
||||||
|
const Home({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _HomeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeState extends State<Home> with AutomaticKeepAliveClientMixin {
|
||||||
|
final PageController _pageController = PageController(initialPage: 1);
|
||||||
|
ClientHolder? _clientHolder;
|
||||||
|
MessagingClient? _mClient;
|
||||||
|
int _currentPageIndex = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() async {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final clientHolder = ClientHolder.of(context);
|
||||||
|
if (_clientHolder != clientHolder) {
|
||||||
|
_clientHolder = clientHolder;
|
||||||
|
_mClient = MessagingClient(
|
||||||
|
apiClient: clientHolder.apiClient,
|
||||||
|
notificationClient: clientHolder.notificationClient,
|
||||||
|
settingsClient: clientHolder.settingsClient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
return Scaffold(
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _currentPageIndex,
|
||||||
|
onDestinationSelected: (int index) async {
|
||||||
|
setState(() {
|
||||||
|
_currentPageIndex = index;
|
||||||
|
});
|
||||||
|
await _pageController.animateToPage(index,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCirc,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(icon: Icon(Icons.folder_copy), label: "Inventory"),
|
||||||
|
NavigationDestination(icon: Icon(Icons.chat), label: "Contacts"),
|
||||||
|
NavigationDestination(icon: Icon(Icons.location_city), label: "Sessions")
|
||||||
|
],
|
||||||
|
),
|
||||||
|
appBar: const FriendsListAppBar(),
|
||||||
|
body: PageView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
controller: _pageController,
|
||||||
|
children: [
|
||||||
|
const Center(child: Text("Not implemented yet"),),
|
||||||
|
ChangeNotifierProvider
|
||||||
|
.value( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
||||||
|
value: _mClient,
|
||||||
|
child: const FriendsList(),
|
||||||
|
),
|
||||||
|
const SessionsList(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// TODO: implement wantKeepAlive
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:contacts_plus_plus/models/message.dart';
|
import 'package:contacts_plus_plus/models/message.dart';
|
||||||
|
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/message_asset.dart';
|
import 'package:contacts_plus_plus/widgets/messages/message_asset.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/message_audio_player.dart';
|
import 'package:contacts_plus_plus/widgets/messages/message_audio_player.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/message_session_invite.dart';
|
import 'package:contacts_plus_plus/widgets/messages/message_session_invite.dart';
|
||||||
|
@ -60,6 +61,13 @@ class MyMessageBubble extends StatelessWidget {
|
||||||
);
|
);
|
||||||
case MessageType.unknown:
|
case MessageType.unknown:
|
||||||
case MessageType.text:
|
case MessageType.text:
|
||||||
|
final formatted = StringFormatter.tryFormat(
|
||||||
|
message.content,
|
||||||
|
baseStyle: Theme
|
||||||
|
.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge,
|
||||||
|
);
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
@ -79,7 +87,7 @@ class MyMessageBubble extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
formatted == null ? Text(
|
||||||
message.content,
|
message.content,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
|
@ -87,7 +95,7 @@ class MyMessageBubble extends StatelessWidget {
|
||||||
.of(context)
|
.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyLarge,
|
.bodyLarge,
|
||||||
),
|
) : RichText(text: formatted, maxLines: null, softWrap: true,),
|
||||||
const SizedBox(height: 6,),
|
const SizedBox(height: 6,),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
@ -148,7 +156,6 @@ class OtherMessageBubble extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var content = message.content;
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case MessageType.sessionInvite:
|
case MessageType.sessionInvite:
|
||||||
return Row(
|
return Row(
|
||||||
|
@ -193,8 +200,14 @@ class OtherMessageBubble extends StatelessWidget {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
case MessageType.unknown:
|
case MessageType.unknown:
|
||||||
rawText:
|
|
||||||
case MessageType.text:
|
case MessageType.text:
|
||||||
|
final formatted = StringFormatter.tryFormat(
|
||||||
|
message.content,
|
||||||
|
baseStyle: Theme
|
||||||
|
.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge,
|
||||||
|
);
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
@ -214,15 +227,15 @@ class OtherMessageBubble extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
formatted == null ? Text(
|
||||||
content,
|
message.content,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
style: Theme
|
style: Theme
|
||||||
.of(context)
|
.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyLarge,
|
.bodyLarge,
|
||||||
),
|
) : RichText(text: formatted, maxLines: null, softWrap: true,),
|
||||||
const SizedBox(height: 6,),
|
const SizedBox(height: 6,),
|
||||||
Text(
|
Text(
|
||||||
_dateFormat.format(message.sendTime),
|
_dateFormat.format(message.sendTime),
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:contacts_plus_plus/client_holder.dart';
|
||||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||||
import 'package:contacts_plus_plus/models/message.dart';
|
import 'package:contacts_plus_plus/models/message.dart';
|
||||||
import 'package:contacts_plus_plus/models/session.dart';
|
import 'package:contacts_plus_plus/models/session.dart';
|
||||||
|
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
|
import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
|
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
|
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
|
||||||
|
@ -18,6 +19,7 @@ class MessageSessionInvite extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sessionInfo = Session.fromMap(jsonDecode(message.content));
|
final sessionInfo = Session.fromMap(jsonDecode(message.content));
|
||||||
|
final formattedName = StringFormatter.tryFormat(sessionInfo.name, baseStyle: Theme.of(context).textTheme.titleMedium);
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(context: context, builder: (context) => SessionPopup(session: sessionInfo));
|
showDialog(context: context, builder: (context) => SessionPopup(session: sessionInfo));
|
||||||
|
@ -38,7 +40,8 @@ class MessageSessionInvite extends StatelessWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,),
|
child: formattedName != null ? RichText(text: formattedName, maxLines: null, softWrap: true) :
|
||||||
|
Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||||
import 'package:contacts_plus_plus/models/session.dart';
|
import 'package:contacts_plus_plus/models/session.dart';
|
||||||
|
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
|
import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -13,6 +14,7 @@ class SessionPopup extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ScrollController userListScrollController = ScrollController();
|
final ScrollController userListScrollController = ScrollController();
|
||||||
final thumbnailUri = Aux.neosDbToHttp(session.thumbnail);
|
final thumbnailUri = Aux.neosDbToHttp(session.thumbnail);
|
||||||
|
final formattedTitle = StringFormatter.tryFormat(session.name);
|
||||||
return Dialog(
|
return Dialog(
|
||||||
insetPadding: const EdgeInsets.all(32),
|
insetPadding: const EdgeInsets.all(32),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
@ -30,7 +32,8 @@ class SessionPopup extends StatelessWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
Text(session.name, style: Theme.of(context).textTheme.titleMedium),
|
formattedTitle == null ?
|
||||||
|
Text(session.name, style: Theme.of(context).textTheme.titleMedium) : RichText(text: formattedTitle),
|
||||||
Text(session.description.isEmpty ? "No description." : session.description, style: Theme.of(context).textTheme.labelMedium),
|
Text(session.description.isEmpty ? "No description." : session.description, style: Theme.of(context).textTheme.labelMedium),
|
||||||
Text("Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}",
|
Text("Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}",
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
@ -114,6 +117,7 @@ class SessionTile extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final formattedTitle = StringFormatter.tryFormat(session.name);
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(context: context, builder: (context) => SessionPopup(session: session));
|
showDialog(context: context, builder: (context) => SessionPopup(session: session));
|
||||||
|
@ -128,7 +132,7 @@ class SessionTile extends StatelessWidget {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(session.name),
|
formattedTitle == null ? Text(session.name) : RichText(text: formattedTitle),
|
||||||
Text("${session.sessionUsers.length}/${session.maxUsers} active users")
|
Text("${session.sessionUsers.length}/${session.maxUsers} active users")
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
60
lib/widgets/sessions/session_tile.dart
Normal file
60
lib/widgets/sessions/session_tile.dart
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||||
|
import 'package:contacts_plus_plus/models/session.dart';
|
||||||
|
import 'package:contacts_plus_plus/string_formatter.dart';
|
||||||
|
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LargeSessionTile extends StatelessWidget {
|
||||||
|
const LargeSessionTile({required this.session, super.key});
|
||||||
|
|
||||||
|
final Session session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final formattedName = StringFormatter.tryFormat(session.name, baseStyle: const TextStyle(color: Colors.white));
|
||||||
|
return InkWell(
|
||||||
|
onTap: (){
|
||||||
|
showDialog(context: context, builder: (context) => SessionPopup(session: session));
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: CachedNetworkImageProvider(
|
||||||
|
Aux.neosDbToHttp(session.thumbnail),
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
|
||||||
|
color: Theme.of(context).colorScheme.background.withAlpha(200),
|
||||||
|
child: formattedName != null ? RichText(text: formattedName, maxLines: 4, overflow: TextOverflow.ellipsis)
|
||||||
|
: Text(session.name.overflow, maxLines: 4, overflow: TextOverflow.ellipsis,),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4),
|
||||||
|
color: Theme.of(context).colorScheme.background.withAlpha(200),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text("${session.sessionUsers.length}/${session.maxUsers}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
113
lib/widgets/sessions/sessions_list.dart
Normal file
113
lib/widgets/sessions/sessions_list.dart
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:contacts_plus_plus/apis/session_api.dart';
|
||||||
|
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||||
|
import 'package:contacts_plus_plus/client_holder.dart';
|
||||||
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||||
|
import 'package:contacts_plus_plus/models/session.dart';
|
||||||
|
import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
|
||||||
|
import 'package:contacts_plus_plus/widgets/friends/expanding_input_fab.dart';
|
||||||
|
import 'package:contacts_plus_plus/widgets/sessions/session_tile.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SessionsList extends StatefulWidget {
|
||||||
|
const SessionsList({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _SessionsListState();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionsListState extends State<SessionsList> with AutomaticKeepAliveClientMixin {
|
||||||
|
Timer? _refreshDelay;
|
||||||
|
Future<List<Session>>? _sessionsFuture;
|
||||||
|
String _searchFilter = "";
|
||||||
|
|
||||||
|
ClientHolder? _clientHolder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() async {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final clientHolder = ClientHolder.of(context);
|
||||||
|
if (_clientHolder != clientHolder) {
|
||||||
|
_clientHolder = clientHolder;
|
||||||
|
final apiClient = _clientHolder!.apiClient;
|
||||||
|
_refreshSessions(apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshSessions(ApiClient client) {
|
||||||
|
if (_refreshDelay?.isActive ?? false) return;
|
||||||
|
_sessionsFuture = SessionApi.getSessions(client);
|
||||||
|
_refreshDelay = Timer(const Duration(seconds: 30), (){});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Session> _filterSessions(List<Session> sessions, {String text=""}) {
|
||||||
|
return sessions.where((element) => element.name.looseMatch(text)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
children: [
|
||||||
|
RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
_refreshSessions(ClientHolder
|
||||||
|
.of(context)
|
||||||
|
.apiClient);
|
||||||
|
await _sessionsFuture; // Keep showing indicator until done;
|
||||||
|
},
|
||||||
|
child: FutureBuilder(
|
||||||
|
future: _sessionsFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final sessions = _filterSessions(snapshot.data as List<Session>, text: _searchFilter);
|
||||||
|
return GridView.builder(
|
||||||
|
itemCount: sessions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return LargeSessionTile(session: sessions[index]);
|
||||||
|
},
|
||||||
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 256),
|
||||||
|
);
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return DefaultErrorWidget(
|
||||||
|
title: "Failed to load sessions",
|
||||||
|
message: snapshot.error.toString(),
|
||||||
|
onRetry: () =>
|
||||||
|
_refreshSessions(ClientHolder
|
||||||
|
.of(context)
|
||||||
|
.apiClient),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const LinearProgressIndicator();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: ExpandingInputFab(
|
||||||
|
onInputChanged: (String text) {
|
||||||
|
setState(() {
|
||||||
|
_searchFilter = text;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
|
if (!expanded) {
|
||||||
|
setState(() {
|
||||||
|
_searchFilter = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
}
|
38
pubspec.lock
38
pubspec.lock
|
@ -25,6 +25,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.13"
|
version: "0.1.13"
|
||||||
|
bbob_dart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bbob_dart
|
||||||
|
sha256: d754e0dfd800582a6f0a43ae4f12db8eb763e89f584674c334a36e0faaddb1f9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -81,6 +89,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
color:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: color
|
||||||
|
sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -150,6 +166,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_bbcode:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_bbcode
|
||||||
|
sha256: "024cb7d3b32d8f7dd155251d09bb92f496aaccb19cfe7313caea2e85d491ac7f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
flutter_blurhash:
|
flutter_blurhash:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -280,6 +304,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
hive:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: hive
|
||||||
|
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.3"
|
||||||
html:
|
html:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -433,13 +465,13 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.2"
|
version: "1.8.2"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4
|
sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.14"
|
version: "2.0.15"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.1.1+1
|
version: 1.2.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.19.6 <3.0.0'
|
sdk: '>=2.19.6 <3.0.0'
|
||||||
|
@ -56,6 +56,10 @@ dependencies:
|
||||||
photo_view: ^0.14.0
|
photo_view: ^0.14.0
|
||||||
file_picker: ^5.2.11
|
file_picker: ^5.2.11
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
|
flutter_bbcode: ^1.4.0
|
||||||
|
color: ^3.0.0
|
||||||
|
path_provider: ^2.0.15
|
||||||
|
hive: ^2.2.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in a new issue