Merge pull request #23 from Nutcake/session-list

Session list
This commit is contained in:
Nutcake 2023-06-04 16:57:44 +02:00 committed by GitHub
commit adf96e887f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1183 additions and 540 deletions

View file

@ -10,4 +10,11 @@ class SessionApi {
final body = jsonDecode(response.body); final body = jsonDecode(response.body);
return Session.fromMap(body); return Session.fromMap(body);
} }
static Future<List<Session>> getSessions(ApiClient client, {SessionFilterSettings? filterSettings}) async {
final response = await client.get("/sessions${filterSettings == null ? "" : filterSettings.buildRequestString()}");
client.checkResponse(response);
final body = jsonDecode(response.body) as List;
return body.map((e) => Session.fromMap(e)).toList();
}
} }

View file

@ -68,6 +68,7 @@ class MessagingClient extends ChangeNotifier {
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient}) MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
: _apiClient = apiClient, _notificationClient = notificationClient { : _apiClient = apiClient, _notificationClient = notificationClient {
debugPrint("mClient created: $hashCode");
Hive.openBox(_messageBoxKey).then((box) async { Hive.openBox(_messageBoxKey).then((box) async {
box.delete(_lastUpdateKey); box.delete(_lastUpdateKey);
await refreshFriendsListWithErrorHandler(); await refreshFriendsListWithErrorHandler();
@ -84,6 +85,7 @@ class MessagingClient extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
debugPrint("mClient disposed: $hashCode");
_autoRefresh?.cancel(); _autoRefresh?.cancel();
_notifyOnlineTimer?.cancel(); _notifyOnlineTimer?.cancel();
_unreadSafeguard?.cancel(); _unreadSafeguard?.cancel();

View file

@ -0,0 +1,35 @@
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/apis/session_api.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/models/session.dart';
import 'package:flutter/foundation.dart';
class SessionClient extends ChangeNotifier {
final ApiClient apiClient;
Future<List<Session>>? _sessionsFuture;
SessionFilterSettings _filterSettings = SessionFilterSettings.empty();
SessionClient({required this.apiClient}) {
reloadSessions();
}
SessionFilterSettings get filterSettings => _filterSettings;
Future<List<Session>>? get sessionsFuture => _sessionsFuture;
set filterSettings(value) {
_filterSettings = value;
reloadSessions();
}
void reloadSessions() {
_sessionsFuture = SessionApi.getSessions(apiClient, filterSettings: _filterSettings).then(
(value) => value.sorted(
(a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length),
),
);
notifyListeners();
}
}

View file

@ -4,10 +4,16 @@ import 'package:contacts_plus_plus/apis/github_api.dart';
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/clients/api_client.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/session_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/friends/friends_list.dart';
import 'package:contacts_plus_plus/widgets/friends/friends_list_app_bar.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/sessions/session_list.dart';
import 'package:contacts_plus_plus/widgets/sessions/session_list_app_bar.dart';
import 'package:contacts_plus_plus/widgets/settings_app_bar.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:contacts_plus_plus/widgets/update_notifier.dart'; import 'package:contacts_plus_plus/widgets/update_notifier.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -22,12 +28,15 @@ import 'models/authentication_data.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
Provider.debugCheckInvalidValueType = null;
await Hive.initFlutter(); await Hive.initFlutter();
final dateFormat = DateFormat.Hms(); final dateFormat = DateFormat.Hms();
Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time)); Logger.root.onRecord.listen(
(event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
final settingsClient = SettingsClient(); final settingsClient = SettingsClient();
await settingsClient.loadSettings(); await settingsClient.loadSettings();
final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault); final newSettings =
settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
AuthenticationData cachedAuth = AuthenticationData.unauthenticated(); AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
try { try {
@ -47,15 +56,28 @@ class ContactsPlusPlus extends StatefulWidget {
} }
class _ContactsPlusPlusState extends State<ContactsPlusPlus> { class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
static const List<Widget> _appBars = [
FriendsListAppBar(
key: ValueKey("friends_list_app_bar"),
),
SessionListAppBar(
key: ValueKey("session_list_app_bar"),
),
SettingsAppBar(
key: ValueKey("settings_app_bar"),
)
];
final Typography _typography = Typography.material2021(platform: TargetPlatform.android); final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
final PageController _pageController = PageController();
late AuthenticationData _authData = widget.cachedAuthentication; late AuthenticationData _authData = widget.cachedAuthentication;
bool _checkedForUpdate = false; bool _checkedForUpdate = false;
int _selectedPage = 0;
void showUpdateDialogOnFirstBuild(BuildContext context) { void showUpdateDialogOnFirstBuild(BuildContext context) {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final settings = ClientHolder final settings = ClientHolder.of(context).settingsClient;
.of(context)
.settingsClient;
if (_checkedForUpdate) return; if (_checkedForUpdate) return;
_checkedForUpdate = true; _checkedForUpdate = true;
GithubApi.getLatestTagName().then((remoteVer) async { GithubApi.getLatestTagName().then((remoteVer) async {
@ -103,61 +125,120 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Phoenix( return Phoenix(
child: Builder( child: Builder(builder: (context) {
builder: (context) { return ClientHolder(
return ClientHolder( settingsClient: widget.settingsClient,
settingsClient: widget.settingsClient, authenticationData: _authData,
authenticationData: _authData, onLogout: () {
onLogout: () { setState(() {
setState(() { _authData = AuthenticationData.unauthenticated();
_authData = AuthenticationData.unauthenticated(); });
}); Phoenix.rebirth(context);
Phoenix.rebirth(context); },
}, child: DynamicColorBuilder(
child: DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( debugShowCheckedModeBanner: false,
debugShowCheckedModeBanner: false, title: 'Contacts++',
title: 'Contacts++', theme: ThemeData(
theme: ThemeData( useMaterial3: true,
useMaterial3: true, textTheme: _typography.black,
textTheme: _typography.black, colorScheme:
colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light), lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light),
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
textTheme: _typography.white, textTheme: _typography.white,
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
), ),
themeMode: ThemeMode.values[widget.settingsClient.currentSettings.themeMode.valueOrDefault], themeMode: ThemeMode.values[widget.settingsClient.currentSettings.themeMode.valueOrDefault],
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder home: Builder(
builder: (context) { // Builder is necessary here since we need a context which has access to the ClientHolder
showUpdateDialogOnFirstBuild(context); builder: (context) {
final clientHolder = ClientHolder.of(context); showUpdateDialogOnFirstBuild(context);
return _authData.isAuthenticated ? final clientHolder = ClientHolder.of(context);
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. return _authData.isAuthenticated
create: (context) => ? MultiProvider(
MessagingClient( providers: [
apiClient: clientHolder.apiClient, Provider(
notificationClient: clientHolder.notificationClient, create: (context) => MessagingClient(
apiClient: clientHolder.apiClient,
notificationClient: clientHolder.notificationClient,
),
dispose: (context, value) => value.dispose(),
), ),
child: const FriendsList(), Provider(
) : create: (context) => SessionClient(
LoginScreen( apiClient: clientHolder.apiClient,
onLoginSuccessful: (AuthenticationData authData) async { ),
if (authData.isAuthenticated) { ),
setState(() { ],
_authData = authData; child: Scaffold(
}); appBar: PreferredSize(
} preferredSize: const Size.fromHeight(kToolbarHeight),
}, child: AnimatedSwitcher(
); duration: const Duration(milliseconds: 200),
} child: _appBars[_selectedPage],
) ),
),
body: PageView(
controller: _pageController,
children: const [
FriendsList(),
SessionList(),
SettingsPage(),
],
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.black)),
color: Theme.of(context).colorScheme.background,
),
child: BottomNavigationBar(
selectedItemColor: Theme.of(context).colorScheme.primary,
currentIndex: _selectedPage,
onTap: (index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
setState(() {
_selectedPage = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.message),
label: "Chat",
),
BottomNavigationBarItem(
icon: Icon(Icons.public),
label: "Sessions",
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: "Settings",
),
],
),
),
),
)
: LoginScreen(
onLoginSuccessful: (AuthenticationData authData) async {
if (authData.isAuthenticated) {
setState(() {
_authData = authData;
});
}
},
);
},
), ),
), ),
); ),
} );
), }),
); );
} }
} }

View file

@ -1,3 +1,4 @@
import 'package:contacts_plus_plus/config.dart';
import 'package:contacts_plus_plus/string_formatter.dart'; import 'package:contacts_plus_plus/string_formatter.dart';
class Session { class Session {
@ -17,11 +18,22 @@ class Session {
final String hostUsername; final String hostUsername;
final SessionAccessLevel accessLevel; final SessionAccessLevel accessLevel;
Session({required this.id, required this.name, required this.sessionUsers, required this.thumbnail, Session({
required this.maxUsers, required this.hasEnded, required this.isValid, required this.description, required this.id,
required this.tags, required this.headlessHost, required this.hostUserId, required this.hostUsername, required this.name,
required this.sessionUsers,
required this.thumbnail,
required this.maxUsers,
required this.hasEnded,
required this.isValid,
required this.description,
required this.tags,
required this.headlessHost,
required this.hostUserId,
required this.hostUsername,
required this.accessLevel, required this.accessLevel,
}) : formattedName = FormatNode.fromText(name), formattedDescription = FormatNode.fromText(description); }) : formattedName = FormatNode.fromText(name),
formattedDescription = FormatNode.fromText(description);
factory Session.none() { factory Session.none() {
return Session( return Session(
@ -37,8 +49,7 @@ class Session {
headlessHost: false, headlessHost: false,
hostUserId: "", hostUserId: "",
hostUsername: "", hostUsername: "",
accessLevel: SessionAccessLevel.unknown accessLevel: SessionAccessLevel.unknown);
);
} }
bool get isNone => id.isEmpty && isValid == false; bool get isNone => id.isEmpty && isValid == false;
@ -62,7 +73,7 @@ class Session {
); );
} }
Map toMap({bool shallow=false}) { Map toMap({bool shallow = false}) {
return { return {
"sessionId": id, "sessionId": id,
"name": name, "name": name,
@ -80,8 +91,6 @@ class Session {
}; };
} }
bool get isLive => !hasEnded && isValid; bool get isLive => !hasEnded && isValid;
} }
@ -101,7 +110,8 @@ enum SessionAccessLevel {
}; };
factory SessionAccessLevel.fromName(String? name) { factory SessionAccessLevel.fromName(String? name) {
return SessionAccessLevel.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase(), return SessionAccessLevel.values.firstWhere(
(element) => element.name.toLowerCase() == name?.toLowerCase(),
orElse: () => SessionAccessLevel.unknown, orElse: () => SessionAccessLevel.unknown,
); );
} }
@ -136,4 +146,56 @@ class SessionUser {
"outputDevice": outputDevice, "outputDevice": outputDevice,
}; };
} }
} }
class SessionFilterSettings {
final String name;
final bool includeEnded;
final bool includeIncompatible;
final String hostName;
final int minActiveUsers;
final bool includeEmptyHeadless;
const SessionFilterSettings({
required this.name,
required this.includeEnded,
required this.includeIncompatible,
required this.hostName,
required this.minActiveUsers,
required this.includeEmptyHeadless,
});
factory SessionFilterSettings.empty() => const SessionFilterSettings(
name: "",
includeEnded: false,
includeIncompatible: false,
hostName: "",
minActiveUsers: 0,
includeEmptyHeadless: true,
);
String buildRequestString() => "?includeEmptyHeadless=$includeEmptyHeadless"
"${"&includeEnded=$includeEnded"}"
"${name.isNotEmpty ? "&name=$name" : ""}"
"${!includeIncompatible ? "&compatibilityHash=${Uri.encodeComponent(Config.latestCompatHash)}" : ""}"
"${hostName.isNotEmpty ? "&hostName=$hostName" : ""}"
"${minActiveUsers > 0 ? "&minActiveUsers=$minActiveUsers" : ""}";
SessionFilterSettings copyWith({
String? name,
bool? includeEnded,
bool? includeIncompatible,
String? hostName,
int? minActiveUsers,
bool? includeEmptyHeadless,
}) {
return SessionFilterSettings(
name: name ?? this.name,
includeEnded: includeEnded ?? this.includeEnded,
includeIncompatible: includeIncompatible ?? this.includeIncompatible,
hostName: hostName ?? this.hostName,
minActiveUsers: minActiveUsers ?? this.minActiveUsers,
includeEmptyHeadless: includeEmptyHeadless ?? this.includeEmptyHeadless,
);
}
}

View file

@ -1,29 +1,11 @@
import 'dart:async';
import 'package:contacts_plus_plus/apis/user_api.dart';
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/models/users/online_status.dart';
import 'package:contacts_plus_plus/models/users/user_status.dart';
import 'package:contacts_plus_plus/widgets/default_error_widget.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/friends/expanding_input_fab.dart';
import 'package:contacts_plus_plus/widgets/friends/friend_list_tile.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_list_tile.dart';
import 'package:contacts_plus_plus/widgets/my_profile_dialog.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:contacts_plus_plus/widgets/friends/user_search.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class MenuItemDefinition {
final String name;
final IconData icon;
final Function() 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});
@ -31,261 +13,78 @@ class FriendsList extends StatefulWidget {
State<FriendsList> createState() => _FriendsListState(); State<FriendsList> createState() => _FriendsListState();
} }
class _FriendsListState extends State<FriendsList> { class _FriendsListState extends State<FriendsList> with AutomaticKeepAliveClientMixin {
Future<UserStatus>? _userStatusFuture;
ClientHolder? _clientHolder;
String _searchFilter = ""; String _searchFilter = "";
@override @override
void didChangeDependencies() async { Widget build(BuildContext context) {
super.didChangeDependencies(); super.build(context);
final clientHolder = ClientHolder.of(context); return ChangeNotifierProvider.value(
if (_clientHolder != clientHolder) { value: Provider.of<MessagingClient>(context, listen: false),
_clientHolder = clientHolder; child: Stack(
_refreshUserStatus(); alignment: Alignment.topCenter,
} children: [
} Consumer<MessagingClient>(builder: (context, mClient, _) {
if (mClient.initStatus == null) {
void _refreshUserStatus() { return const LinearProgressIndicator();
final apiClient = _clientHolder!.apiClient; } else if (mClient.initStatus!.isNotEmpty) {
_userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async { return Column(
if (value.onlineStatus == OnlineStatus.offline) { children: [
final newStatus = value.copyWith( Expanded(
onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus child: DefaultErrorWidget(
.valueOrDefault] message: mClient.initStatus,
); onRetry: () async {
await UserApi.setStatus(apiClient, status: newStatus); mClient.resetInitStatus();
return newStatus; mClient.refreshFriendsListWithErrorHandler();
} },
return value; ),
}); ),
],
);
} else {
var friends = List.from(mClient.cachedFriends); // Explicit copy.
if (_searchFilter.isNotEmpty) {
friends = friends
.where((element) => element.username.toLowerCase().contains(_searchFilter.toLowerCase()))
.toList();
friends.sort((a, b) => a.username.length.compareTo(b.username.length));
}
return ListView.builder(
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
itemCount: friends.length,
itemBuilder: (context, index) {
final friend = friends[index];
final unreads = mClient.getUnreadsForFriend(friend);
return FriendListTile(
friend: friend,
unreads: unreads.length,
);
},
);
}
}),
Align(
alignment: Alignment.bottomCenter,
child: ExpandingInputFab(
onInputChanged: (String text) {
setState(() {
_searchFilter = text;
});
},
onExpansionChanged: (expanded) {
if (!expanded) {
setState(() {
_searchFilter = "";
});
}
},
),
),
],
),
);
} }
@override @override
Widget build(BuildContext context) { bool get wantKeepAlive => true;
final clientHolder = ClientHolder.of(context); }
return Scaffold(
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(context),),
),
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(context),),
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 = 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 const MyProfileDialog();
},
);
},
),
].map((item) =>
PopupMenuItem<MenuItemDefinition>(
value: item,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name),
Icon(item.icon),
],
),
),
).toList(),
),
)
],
),
body: Stack(
alignment: Alignment.topCenter,
children: [
Consumer<MessagingClient>(
builder: (context, mClient, _) {
if (mClient.initStatus == null) {
return const LinearProgressIndicator();
} else if (mClient.initStatus!.isNotEmpty) {
return Column(
children: [
Expanded(
child: DefaultErrorWidget(
message: mClient.initStatus,
onRetry: () async {
mClient.resetInitStatus();
mClient.refreshFriendsListWithErrorHandler();
},
),
),
],
);
} else {
var friends = List.from(mClient.cachedFriends); // Explicit copy.
if (_searchFilter.isNotEmpty) {
friends = friends.where((element) =>
element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList();
friends.sort((a, b) => a.username.length.compareTo(b.username.length));
}
return ListView.builder(
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
itemCount: friends.length,
itemBuilder: (context, index) {
final friend = friends[index];
final unreads = mClient.getUnreadsForFriend(friend);
return FriendListTile(
friend: friend,
unreads: unreads.length,
);
},
);
}
}
),
Align(
alignment: Alignment.bottomCenter,
child: ExpandingInputFab(
onInputChanged: (String text) {
setState(() {
_searchFilter = text;
});
},
onExpansionChanged: (expanded) {
if (!expanded) {
setState(() {
_searchFilter = "";
});
}
},
),
),
],
),
);
}
}

View file

@ -0,0 +1,231 @@
import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/users/online_status.dart';
import 'package:contacts_plus_plus/models/users/user_status.dart';
import 'package:contacts_plus_plus/widgets/friends/user_search.dart';
import 'package:contacts_plus_plus/widgets/my_profile_dialog.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class FriendsListAppBar extends StatefulWidget {
const FriendsListAppBar({super.key});
@override
State<StatefulWidget> createState() => _FriendsListAppBarState();
}
class _FriendsListAppBarState extends State<FriendsListAppBar> with AutomaticKeepAliveClientMixin {
Future<UserStatus>? _userStatusFuture;
ClientHolder? _clientHolder;
@override
void didChangeDependencies() async {
super.didChangeDependencies();
final clientHolder = ClientHolder.of(context);
if (_clientHolder != clientHolder) {
_clientHolder = clientHolder;
_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) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<MessagingClient>(context, listen: false),
child: AppBar(
title: const Text("Contacts++"),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
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(context),
),
),
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(context),
),
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 =
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 const MyProfileDialog();
},
);
},
),
]
.map(
(item) => PopupMenuItem<MenuItemDefinition>(
value: item,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name),
Icon(item.icon),
],
),
),
)
.toList(),
),
)
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: Colors.black,
),
),
),
);
}
@override
bool get wantKeepAlive => true;
}
class MenuItemDefinition {
final String name;
final IconData icon;
final Function() onTap;
const MenuItemDefinition({required this.name, required this.icon, required this.onTap});
}

View file

@ -162,7 +162,6 @@ class _LoginScreenState extends State<LoginScreen> {
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
child: TextField( child: TextField(
autofocus: true,
controller: _usernameController, controller: _usernameController,
onEditingComplete: () => _passwordFocusNode.requestFocus(), onEditingComplete: () => _passwordFocusNode.requestFocus(),
decoration: InputDecoration( decoration: InputDecoration(

View file

@ -5,9 +5,8 @@ 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/widgets/formatted_text.dart'; import 'package:contacts_plus_plus/widgets/formatted_text.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/message_state_indicator.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
import 'package:contacts_plus_plus/widgets/session_view.dart'; import 'package:contacts_plus_plus/widgets/sessions/session_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MessageSessionInvite extends StatelessWidget { class MessageSessionInvite extends StatelessWidget {

View file

@ -3,7 +3,7 @@ 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/widgets/formatted_text.dart'; import 'package:contacts_plus_plus/widgets/formatted_text.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/session_view.dart'; import 'package:contacts_plus_plus/widgets/sessions/session_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SessionPopup extends StatelessWidget { class SessionPopup extends StatelessWidget {

View file

@ -1,185 +0,0 @@
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/widgets/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
class SessionView extends StatelessWidget {
const SessionView({required this.session, super.key});
final Session session;
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast),
slivers: [
SliverAppBar(
leading: IconButton(
icon: const Icon(
Icons.arrow_back_outlined,
),
onPressed: () {
Navigator.of(context).pop();
},
),
pinned: true,
snap: false,
floating: false,
expandedHeight: 192,
surfaceTintColor: Theme.of(context).colorScheme.surfaceVariant,
centerTitle: true,
title: FormattedText(
session.formattedName,
maxLines: 1,
style: Theme.of(context).textTheme.titleLarge,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Row(
children: [
Expanded(
child: Container(
width: double.infinity,
height: 1,
color: Colors.black,
)),
],
),
),
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: CachedNetworkImage(
imageUrl: Aux.neosDbToHttp(session.thumbnail),
imageBuilder: (context, image) {
return InkWell(
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoView(
minScale: PhotoViewComputedScale.contained,
imageProvider: image,
heroAttributes: PhotoViewHeroAttributes(tag: session.id),
),
),
);
},
child: Hero(
tag: session.id,
child: Image(
image: image,
fit: BoxFit.cover,
),
),
);
},
errorWidget: (context, url, error) => const Icon(
Icons.broken_image,
size: 64,
),
placeholder: (context, uri) => const Center(child: CircularProgressIndicator()),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8),
child: session.formattedDescription.isEmpty
? Text("No description", style: Theme.of(context).textTheme.labelLarge)
: FormattedText(
session.formattedDescription,
style: Theme.of(context).textTheme.titleLarge,
),
),
const ListSectionHeader(
leadingText: "Tags:",
showLine: false,
),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8.0),
child: Text(
session.tags.isEmpty ? "None" : session.tags.join(", "),
style: Theme.of(context).textTheme.labelMedium,
textAlign: TextAlign.start,
softWrap: true,
),
),
const ListSectionHeader(
leadingText: "Details:",
showLine: false,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Access: ",
style: Theme.of(context).textTheme.labelLarge,
),
Text(
session.accessLevel.toReadableString(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Headless: ",
style: Theme.of(context).textTheme.labelLarge,
),
Text(
session.headlessHost ? "Yes" : "No",
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
ListSectionHeader(
leadingText: "Users",
trailingText:
"${session.sessionUsers.length.toString().padLeft(2, "0")}/${session.maxUsers.toString().padLeft(2, "0")}",
showLine: false,
),
],
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final user = session.sessionUsers[index % session.sessionUsers.length];
return ListTile(
dense: true,
title: Text(
user.username,
textAlign: TextAlign.start,
),
subtitle: Text(
user.isPresent ? "Active" : "Inactive",
textAlign: TextAlign.start,
),
);
},
childCount: session.sessionUsers.length,
),
)
],
),
);
}
}

View file

@ -0,0 +1,190 @@
import 'dart:math';
import 'package:contacts_plus_plus/clients/session_client.dart';
import 'package:contacts_plus_plus/models/session.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SessionFilterDialog extends StatefulWidget {
const SessionFilterDialog({required this.lastFilter, super.key});
final SessionFilterSettings lastFilter;
@override
State<StatefulWidget> createState() => _SessionFilterDialogState();
}
class _SessionFilterDialogState extends State<SessionFilterDialog> {
final TextEditingController _sessionNameController = TextEditingController();
final TextEditingController _hostNameController = TextEditingController();
late SessionFilterSettings _currentFilter;
@override
void didUpdateWidget(covariant SessionFilterDialog oldWidget) {
super.didUpdateWidget(oldWidget);
_currentFilter = widget.lastFilter;
if (oldWidget.lastFilter != widget.lastFilter) {
_sessionNameController.text = widget.lastFilter.name;
_hostNameController.text = widget.lastFilter.hostName;
}
}
@override
void initState() {
super.initState();
_currentFilter = widget.lastFilter;
_sessionNameController.text = widget.lastFilter.name;
_hostNameController.text = widget.lastFilter.hostName;
}
@override
void dispose() {
_sessionNameController.dispose();
_hostNameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Filter"),
content: SizedBox(
width: double.infinity,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TextField(
controller: _sessionNameController,
maxLines: 1,
onChanged: (value) {
_currentFilter = _currentFilter.copyWith(name: value);
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
),
labelText: 'Session Name',
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TextField(
controller: _hostNameController,
onChanged: (value) {
_currentFilter = _currentFilter.copyWith(hostName: value);
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
),
labelText: 'Host Name',
),
),
),
Row(
mainAxisSize: MainAxisSize.max,
children: [
const Text("Minimum Users"),
const Spacer(),
IconButton(
onPressed: () {
setState(() {
_currentFilter =
_currentFilter.copyWith(minActiveUsers: max(0, _currentFilter.minActiveUsers - 1));
});
},
icon: const Icon(Icons.remove_circle_outline),
),
Text(
"${_currentFilter.minActiveUsers}",
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
onPressed: () {
setState(() {
_currentFilter = _currentFilter.copyWith(minActiveUsers: _currentFilter.minActiveUsers + 1, includeEmptyHeadless: false);
});
},
icon: const Icon(Icons.add_circle_outline),
),
],
),
SessionFilterCheckbox(
label: "Include Ended",
value: _currentFilter.includeEnded,
onChanged: (value) {
setState(() {
_currentFilter = _currentFilter.copyWith(includeEnded: value);
});
},
),
SessionFilterCheckbox(
label: "Include Empty Headless",
value: _currentFilter.includeEmptyHeadless && _currentFilter.minActiveUsers == 0,
onChanged: _currentFilter.minActiveUsers > 0 ? null : (value) {
setState(() {
_currentFilter = _currentFilter.copyWith(includeEmptyHeadless: value);
});
},
),
SessionFilterCheckbox(
label: "Include Incompatible",
value: _currentFilter.includeIncompatible,
onChanged: (value) {
setState(() {
_currentFilter = _currentFilter.copyWith(includeIncompatible: value);
});
},
),
],
),
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
Provider.of<SessionClient>(context, listen: false).filterSettings = _currentFilter;
Navigator.of(context).pop();
},
child: const Text("Okay"),
),
],
);
}
}
class SessionFilterCheckbox extends StatelessWidget {
const SessionFilterCheckbox({required this.label, this.onChanged, this.value, super.key});
final String label;
final void Function(bool? value)? onChanged;
final bool? value;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Checkbox(
value: value,
onChanged: onChanged,
),
],
);
}
}

View file

@ -0,0 +1,161 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/clients/session_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/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/sessions/session_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SessionList extends StatefulWidget {
const SessionList({super.key});
@override
State<SessionList> createState() => _SessionListState();
}
class _SessionListState extends State<SessionList> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<SessionClient>(context),
child: Consumer<SessionClient>(
builder: (BuildContext context, SessionClient sClient, Widget? child) {
return FutureBuilder<List<Session>>(
future: sClient.sessionsFuture,
builder: (context, snapshot) {
final data = snapshot.data ?? [];
return Stack(
children: [
RefreshIndicator(
onRefresh: () async {
sClient.reloadSessions();
try {
await sClient.sessionsFuture;
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
},
child: data.isEmpty && snapshot.connectionState == ConnectionState.done
? const DefaultErrorWidget(
title: "No Sessions Found",
message: "Try to adjust your filters",
iconOverride: Icons.public_off,
)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: GridView.builder(
padding: const EdgeInsets.only(top: 10),
itemCount: data.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 256,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
childAspectRatio: .8,
),
itemBuilder: (context, index) {
final session = data[index];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => SessionView(session: session)));
},
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Hero(
tag: session.id,
child: CachedNetworkImage(
imageUrl: Aux.neosDbToHttp(session.thumbnail),
fit: BoxFit.cover,
errorWidget: (context, url, error) => const Center(
child: Icon(
Icons.broken_image,
size: 64,
),
),
placeholder: (context, uri) =>
const Center(child: CircularProgressIndicator()),
),
),
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: FormattedText(
session.formattedName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(
height: 4,
),
Row(
children: [
Expanded(
child: Text(
"${session.sessionUsers.length.toString().padLeft(2, "0")}/${session.maxUsers.toString().padLeft(2, "0")} Online",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(.5),
),
),
),
],
),
],
),
),
)
],
),
),
);
},
),
),
),
if (snapshot.connectionState == ConnectionState.waiting) const LinearProgressIndicator()
],
);
},
);
},
),
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,48 @@
import 'package:contacts_plus_plus/clients/session_client.dart';
import 'package:contacts_plus_plus/widgets/sessions/session_filter_dialog.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SessionListAppBar extends StatefulWidget {
const SessionListAppBar({super.key});
@override
State<SessionListAppBar> createState() => _SessionListAppBarState();
}
class _SessionListAppBarState extends State<SessionListAppBar> {
@override
Widget build(BuildContext context) {
return AppBar(
title: const Text("Sessions"),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: Colors.black,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: IconButton(
onPressed: () async {
final sessionClient = Provider.of<SessionClient>(context, listen: false);
await showDialog(
context: context,
builder: (context) => Provider.value(
value: sessionClient,
child: SessionFilterDialog(
lastFilter: sessionClient.filterSettings,
),
),
);
},
icon: const Icon(Icons.filter_alt_outlined),
),
)
],
);
}
}

View file

@ -0,0 +1,204 @@
import 'package:cached_network_image/cached_network_image.dart';
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/models/session.dart';
import 'package:contacts_plus_plus/widgets/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
class SessionView extends StatefulWidget {
const SessionView({required this.session, super.key});
final Session session;
@override
State<SessionView> createState() => _SessionViewState();
}
class _SessionViewState extends State<SessionView> {
Future<Session>? _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = Future.value(widget.session);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _sessionFuture,
builder: (context, snapshot) {
final session = snapshot.data ?? widget.session;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(
Icons.arrow_back_outlined,
),
onPressed: () {
Navigator.of(context).pop();
},
),
title: FormattedText(
session.formattedName,
maxLines: 1,
style: Theme.of(context).textTheme.titleLarge,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Row(
children: [
Expanded(
child: Container(
width: double.infinity,
height: 1,
color: Colors.black,
),
),
],
),
),
),
body: RefreshIndicator(
onRefresh: () async {
setState(() {
_sessionFuture = SessionApi.getSession(ClientHolder.of(context).apiClient, sessionId: session.id);
});
await _sessionFuture;
},
child: ListView(
children: <Widget>[
SizedBox(
height: 192,
child: Hero(
tag: session.id,
child: CachedNetworkImage(
imageUrl: Aux.neosDbToHttp(session.thumbnail),
imageBuilder: (context, image) {
return Material(
child: InkWell(
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoView(
minScale: PhotoViewComputedScale.contained,
imageProvider: image,
heroAttributes: PhotoViewHeroAttributes(tag: session.id),
),
),
);
},
child: Image(
image: image,
fit: BoxFit.cover,
),
),
);
},
errorWidget: (context, url, error) => const Icon(
Icons.broken_image,
size: 64,
),
placeholder: (context, uri) => const Center(child: CircularProgressIndicator()),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8),
child: session.formattedDescription.isEmpty
? Text("No description", style: Theme.of(context).textTheme.labelLarge)
: FormattedText(
session.formattedDescription,
style: Theme.of(context).textTheme.titleLarge,
),
),
const ListSectionHeader(
leadingText: "Tags:",
showLine: false,
),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8.0),
child: Text(
session.tags.isEmpty ? "None" : session.tags.join(", "),
style: Theme.of(context).textTheme.labelMedium,
textAlign: TextAlign.start,
softWrap: true,
),
),
const ListSectionHeader(
leadingText: "Details:",
showLine: false,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Access: ",
style: Theme.of(context).textTheme.labelLarge,
),
Text(
session.accessLevel.toReadableString(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Headless: ",
style: Theme.of(context).textTheme.labelLarge,
),
Text(
session.headlessHost ? "Yes" : "No",
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
ListSectionHeader(
leadingText: "Users",
trailingText:
"${session.sessionUsers.length.toString().padLeft(2, "0")}/${session.maxUsers.toString().padLeft(2, "0")}",
showLine: false,
),
],
),
),
] +
session.sessionUsers
.map((user) => ListTile(
dense: true,
title: Text(
user.username,
textAlign: TextAlign.start,
),
subtitle: Text(
user.isPresent ? "Active" : "Inactive",
textAlign: TextAlign.start,
),
))
.toList(),
),
),
);
},
);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class SettingsAppBar extends StatelessWidget {
const SettingsAppBar({super.key});
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
title: const Text("Settings"),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: Colors.black,
),
),
);
}
}

View file

@ -11,17 +11,7 @@ class SettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sClient = ClientHolder.of(context).settingsClient; final sClient = ClientHolder.of(context).settingsClient;
return Scaffold( return ListView(
appBar: AppBar(
leading: IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.arrow_back),
),
title: const Text("Settings"),
),
body: ListView(
children: [ children: [
const ListSectionHeader(leadingText: "Notifications"), const ListSectionHeader(leadingText: "Notifications"),
BooleanSettingsTile( BooleanSettingsTile(
@ -110,8 +100,7 @@ class SettingsPage extends StatelessWidget {
}, },
) )
], ],
), );
);
} }
} }