diff --git a/lib/apis/session_api.dart b/lib/apis/session_api.dart index fab8d1c..a453fbd 100644 --- a/lib/apis/session_api.dart +++ b/lib/apis/session_api.dart @@ -10,4 +10,11 @@ class SessionApi { final body = jsonDecode(response.body); return Session.fromMap(body); } + + static Future> 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(); + } } \ No newline at end of file diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 0203e76..2297d25 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -68,6 +68,7 @@ class MessagingClient extends ChangeNotifier { MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient}) : _apiClient = apiClient, _notificationClient = notificationClient { + debugPrint("mClient created: $hashCode"); Hive.openBox(_messageBoxKey).then((box) async { box.delete(_lastUpdateKey); await refreshFriendsListWithErrorHandler(); @@ -84,6 +85,7 @@ class MessagingClient extends ChangeNotifier { @override void dispose() { + debugPrint("mClient disposed: $hashCode"); _autoRefresh?.cancel(); _notifyOnlineTimer?.cancel(); _unreadSafeguard?.cancel(); diff --git a/lib/clients/session_client.dart b/lib/clients/session_client.dart new file mode 100644 index 0000000..37ff518 --- /dev/null +++ b/lib/clients/session_client.dart @@ -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>? _sessionsFuture; + + SessionFilterSettings _filterSettings = SessionFilterSettings.empty(); + + SessionClient({required this.apiClient}) { + reloadSessions(); + } + + SessionFilterSettings get filterSettings => _filterSettings; + + Future>? 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(); + } +} diff --git a/lib/main.dart b/lib/main.dart index ef86464..dee27e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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/clients/api_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/models/sem_ver.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/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:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; @@ -22,12 +28,15 @@ import 'models/authentication_data.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + Provider.debugCheckInvalidValueType = null; await Hive.initFlutter(); 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(); 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 AuthenticationData cachedAuth = AuthenticationData.unauthenticated(); try { @@ -47,15 +56,28 @@ class ContactsPlusPlus extends StatefulWidget { } class _ContactsPlusPlusState extends State { + static const List _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 PageController _pageController = PageController(); late AuthenticationData _authData = widget.cachedAuthentication; + bool _checkedForUpdate = false; + int _selectedPage = 0; void showUpdateDialogOnFirstBuild(BuildContext context) { final navigator = Navigator.of(context); - final settings = ClientHolder - .of(context) - .settingsClient; + final settings = ClientHolder.of(context).settingsClient; if (_checkedForUpdate) return; _checkedForUpdate = true; GithubApi.getLatestTagName().then((remoteVer) async { @@ -103,61 +125,120 @@ class _ContactsPlusPlusState extends State { @override Widget build(BuildContext context) { return Phoenix( - child: Builder( - builder: (context) { - return ClientHolder( - settingsClient: widget.settingsClient, - authenticationData: _authData, - onLogout: () { - setState(() { - _authData = AuthenticationData.unauthenticated(); - }); - Phoenix.rebirth(context); - }, - child: DynamicColorBuilder( - builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Contacts++', - theme: ThemeData( - useMaterial3: true, - textTheme: _typography.black, - colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light), - ), - darkTheme: ThemeData( - useMaterial3: true, - textTheme: _typography.white, - colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), - ), - 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 - builder: (context) { - showUpdateDialogOnFirstBuild(context); - final clientHolder = ClientHolder.of(context); - return _authData.isAuthenticated ? - ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. - create: (context) => - MessagingClient( - apiClient: clientHolder.apiClient, - notificationClient: clientHolder.notificationClient, + child: Builder(builder: (context) { + return ClientHolder( + settingsClient: widget.settingsClient, + authenticationData: _authData, + onLogout: () { + setState(() { + _authData = AuthenticationData.unauthenticated(); + }); + Phoenix.rebirth(context); + }, + child: DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Contacts++', + theme: ThemeData( + useMaterial3: true, + textTheme: _typography.black, + colorScheme: + lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light), + ), + darkTheme: ThemeData( + useMaterial3: true, + textTheme: _typography.white, + colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), + ), + 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 + builder: (context) { + showUpdateDialogOnFirstBuild(context); + final clientHolder = ClientHolder.of(context); + return _authData.isAuthenticated + ? MultiProvider( + providers: [ + Provider( + create: (context) => MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + ), + dispose: (context, value) => value.dispose(), ), - child: const FriendsList(), - ) : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); - } - }, - ); - } - ) + Provider( + create: (context) => SessionClient( + apiClient: clientHolder.apiClient, + ), + ), + ], + 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; + }); + } + }, + ); + }, ), ), - ); - } - ), + ), + ); + }), ); } } diff --git a/lib/models/session.dart b/lib/models/session.dart index a2c428d..4febf70 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -1,3 +1,4 @@ +import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/string_formatter.dart'; class Session { @@ -17,11 +18,22 @@ class Session { final String hostUsername; final SessionAccessLevel accessLevel; - Session({required this.id, 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, + Session({ + required this.id, + 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, - }) : formattedName = FormatNode.fromText(name), formattedDescription = FormatNode.fromText(description); + }) : formattedName = FormatNode.fromText(name), + formattedDescription = FormatNode.fromText(description); factory Session.none() { return Session( @@ -37,8 +49,7 @@ class Session { headlessHost: false, hostUserId: "", hostUsername: "", - accessLevel: SessionAccessLevel.unknown - ); + accessLevel: SessionAccessLevel.unknown); } bool get isNone => id.isEmpty && isValid == false; @@ -62,7 +73,7 @@ class Session { ); } - Map toMap({bool shallow=false}) { + Map toMap({bool shallow = false}) { return { "sessionId": id, "name": name, @@ -80,8 +91,6 @@ class Session { }; } - - bool get isLive => !hasEnded && isValid; } @@ -101,7 +110,8 @@ enum SessionAccessLevel { }; 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, ); } @@ -136,4 +146,56 @@ class SessionUser { "outputDevice": outputDevice, }; } -} \ No newline at end of file +} + +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, + ); + } +} diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index 8549fcb..4739cc7 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -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/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/friends/expanding_input_fab.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:intl/intl.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 { const FriendsList({super.key}); @@ -31,261 +13,78 @@ class FriendsList extends StatefulWidget { State createState() => _FriendsListState(); } -class _FriendsListState extends State { - Future? _userStatusFuture; - ClientHolder? _clientHolder; +class _FriendsListState extends State with AutomaticKeepAliveClientMixin { String _searchFilter = ""; @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; - }); + Widget build(BuildContext context) { + super.build(context); + return ChangeNotifierProvider.value( + value: Provider.of(context, listen: false), + child: Stack( + alignment: Alignment.topCenter, + children: [ + Consumer(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 = ""; + }); + } + }, + ), + ), + ], + ), + ); } @override - Widget build(BuildContext context) { - 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( - 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( - 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( - 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(context, listen: false); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - ChangeNotifierProvider.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( - value: item, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(item.name), - Icon(item.icon), - ], - ), - ), - ).toList(), - ), - ) - ], - ), - body: Stack( - alignment: Alignment.topCenter, - children: [ - Consumer( - 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 = ""; - }); - } - }, - ), - ), - ], - ), - ); - } -} \ No newline at end of file + bool get wantKeepAlive => true; +} diff --git a/lib/widgets/friends/friends_list_app_bar.dart b/lib/widgets/friends/friends_list_app_bar.dart new file mode 100644 index 0000000..07b2ab1 --- /dev/null +++ b/lib/widgets/friends/friends_list_app_bar.dart @@ -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 createState() => _FriendsListAppBarState(); +} + +class _FriendsListAppBarState extends State with AutomaticKeepAliveClientMixin { + Future? _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(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( + 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( + 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( + 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(context, listen: false); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.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( + 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}); +} diff --git a/lib/widgets/login_screen.dart b/lib/widgets/login_screen.dart index 541caf9..ac147d6 100644 --- a/lib/widgets/login_screen.dart +++ b/lib/widgets/login_screen.dart @@ -162,7 +162,6 @@ class _LoginScreenState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: TextField( - autofocus: true, controller: _usernameController, onEditingComplete: () => _passwordFocusNode.requestFocus(), decoration: InputDecoration( diff --git a/lib/widgets/messages/message_session_invite.dart b/lib/widgets/messages/message_session_invite.dart index 0eb0a56..6754bb7 100644 --- a/lib/widgets/messages/message_session_invite.dart +++ b/lib/widgets/messages/message_session_invite.dart @@ -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/widgets/formatted_text.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/session_view.dart'; +import 'package:contacts_plus_plus/widgets/sessions/session_view.dart'; import 'package:flutter/material.dart'; class MessageSessionInvite extends StatelessWidget { diff --git a/lib/widgets/messages/messages_session_header.dart b/lib/widgets/messages/messages_session_header.dart index 13a9be1..34439ca 100644 --- a/lib/widgets/messages/messages_session_header.dart +++ b/lib/widgets/messages/messages_session_header.dart @@ -3,7 +3,7 @@ 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/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'; class SessionPopup extends StatelessWidget { diff --git a/lib/widgets/session_view.dart b/lib/widgets/session_view.dart deleted file mode 100644 index e4f3a10..0000000 --- a/lib/widgets/session_view.dart +++ /dev/null @@ -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, - ), - ) - ], - ), - ); - } -} diff --git a/lib/widgets/sessions/session_filter_dialog.dart b/lib/widgets/sessions/session_filter_dialog.dart new file mode 100644 index 0000000..891373e --- /dev/null +++ b/lib/widgets/sessions/session_filter_dialog.dart @@ -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 createState() => _SessionFilterDialogState(); +} + +class _SessionFilterDialogState extends State { + 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(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, + ), + ], + ); + } +} diff --git a/lib/widgets/sessions/session_list.dart b/lib/widgets/sessions/session_list.dart new file mode 100644 index 0000000..a221e2f --- /dev/null +++ b/lib/widgets/sessions/session_list.dart @@ -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 createState() => _SessionListState(); +} + +class _SessionListState extends State with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return ChangeNotifierProvider.value( + value: Provider.of(context), + child: Consumer( + builder: (BuildContext context, SessionClient sClient, Widget? child) { + return FutureBuilder>( + 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; +} diff --git a/lib/widgets/sessions/session_list_app_bar.dart b/lib/widgets/sessions/session_list_app_bar.dart new file mode 100644 index 0000000..f3efbd3 --- /dev/null +++ b/lib/widgets/sessions/session_list_app_bar.dart @@ -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 createState() => _SessionListAppBarState(); +} + +class _SessionListAppBarState extends State { + @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(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), + ), + ) + ], + ); + } +} diff --git a/lib/widgets/sessions/session_view.dart b/lib/widgets/sessions/session_view.dart new file mode 100644 index 0000000..a461a3c --- /dev/null +++ b/lib/widgets/sessions/session_view.dart @@ -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 createState() => _SessionViewState(); +} + +class _SessionViewState extends State { + + Future? _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: [ + 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(), + ), + ), + ); + }, + + ); + } +} diff --git a/lib/widgets/settings_app_bar.dart b/lib/widgets/settings_app_bar.dart new file mode 100644 index 0000000..1b226bf --- /dev/null +++ b/lib/widgets/settings_app_bar.dart @@ -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, + ), + ), + ); + } + +} \ No newline at end of file diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index f72ab9f..28e0895 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -11,17 +11,7 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { final sClient = ClientHolder.of(context).settingsClient; - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.arrow_back), - ), - title: const Text("Settings"), - ), - body: ListView( + return ListView( children: [ const ListSectionHeader(leadingText: "Notifications"), BooleanSettingsTile( @@ -110,8 +100,7 @@ class SettingsPage extends StatelessWidget { }, ) ], - ), - ); + ); } }