From e8ea2c97972e2373f07290371971a8ced59ea399 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Tue, 30 May 2023 15:09:38 +0200 Subject: [PATCH 1/4] Add barebones session list viewer --- lib/apis/session_api.dart | 7 + lib/widgets/friends/friends_list.dart | 263 +++++++++++++------------- lib/widgets/session_list.dart | 159 ++++++++++++++++ lib/widgets/session_view.dart | 60 +++--- 4 files changed, 330 insertions(+), 159 deletions(-) create mode 100644 lib/widgets/session_list.dart diff --git a/lib/apis/session_api.dart b/lib/apis/session_api.dart index fab8d1c..952e45d 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) async { + final response = await client.get("/sessions"); + 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/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index 8549fcb..b606d1c 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -9,13 +9,13 @@ 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/session_list.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; @@ -51,9 +51,8 @@ class _FriendsListState extends State { _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] - ); + onlineStatus: + OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus.valueOrDefault]); await UserApi.setStatus(apiClient, status: newStatus); return newStatus; } @@ -78,7 +77,11 @@ class _FriendsListState extends State { children: [ Padding( padding: const EdgeInsets.only(right: 8.0), - child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color(context),), + child: Icon( + Icons.circle, + size: 16, + color: userStatus.onlineStatus.color(context), + ), ), Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), ], @@ -89,50 +92,50 @@ class _FriendsListState extends State { setState(() { _userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now())); }); - final settingsClient = ClientHolder - .of(context) - .settingsClient; + 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."))); + 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)!), - ], - ), + 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()); + ), + ) + .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) - ), + 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); + _userStatusFuture = + UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient.userId); }); }, icon: const Icon(Icons.warning), @@ -141,10 +144,7 @@ class _FriendsListState extends State { } else { return TextButton.icon( style: TextButton.styleFrom( - disabledForegroundColor: Theme - .of(context) - .colorScheme - .onSurface, + disabledForegroundColor: Theme.of(context).colorScheme.onSurface, ), onPressed: null, icon: Container( @@ -153,17 +153,13 @@ class _FriendsListState extends State { margin: const EdgeInsets.only(right: 4), child: CircularProgressIndicator( strokeWidth: 2, - color: Theme - .of(context) - .colorScheme - .onSurface, + color: Theme.of(context).colorScheme.onSurface, ), ), label: const Text("Loading"), ); } - } - ), + }), Padding( padding: const EdgeInsets.only(left: 4, right: 4), child: PopupMenuButton( @@ -171,55 +167,63 @@ class _FriendsListState extends State { 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), - ], + 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(), ), ), - ).toList(), + ); + }, + ), + MenuItemDefinition( + name: "My Profile", + icon: Icons.person, + onTap: () async { + await showDialog( + context: context, + builder: (context) { + return const MyProfileDialog(); + }, + ); + }, + ), + MenuItemDefinition( + name: "Sessions", + icon: Icons.location_city, + onTap: () async { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SessionList()), + ); + }, + ), + ].map( + (item) => PopupMenuItem( + value: item, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.name), + Icon(item.icon), + ], + ), + ), + ) + .toList(), ), ) ], @@ -227,46 +231,45 @@ class _FriendsListState extends State { 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, - ); - }, - ); - } + 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( @@ -288,4 +291,4 @@ class _FriendsListState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/session_list.dart b/lib/widgets/session_list.dart new file mode 100644 index 0000000..3f343a8 --- /dev/null +++ b/lib/widgets/session_list.dart @@ -0,0 +1,159 @@ +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/session_view.dart'; +import 'package:flutter/material.dart'; + +class SessionList extends StatefulWidget { + const SessionList({super.key}); + + @override + State createState() => _SessionListState(); +} + +class _SessionListState extends State { + Future>? _sessionsFuture; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _sessionsFuture = SessionApi.getSessions(ClientHolder.of(context).apiClient); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + scrolledUnderElevation: 0, + title: const Text("Sessions"), + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Row( + children: [ + Expanded( + child: Container( + width: double.infinity, + height: 1, + color: Colors.black, + ), + ), + ], + ), + ), + ), + body: FutureBuilder>( + future: _sessionsFuture, + builder: (context, snapshot) { + final data = snapshot.data ?? []; + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: GridView.builder( + padding: const EdgeInsets.only(top: 10), + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), + 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() + ], + ); + }, + ), + ); + } +} diff --git a/lib/widgets/session_view.dart b/lib/widgets/session_view.dart index a31447a..cc053aa 100644 --- a/lib/widgets/session_view.dart +++ b/lib/widgets/session_view.dart @@ -52,36 +52,38 @@ class SessionView extends StatelessWidget { ), 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), - ), + background: 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, ), - ); - }, - child: Hero( - tag: session.id, - child: Image( - image: image, - fit: BoxFit.cover, ), - ), - ); - }, - errorWidget: (context, url, error) => const Icon( - Icons.broken_image, - size: 64, + ); + }, + errorWidget: (context, url, error) => const Icon( + Icons.broken_image, + size: 64, + ), + placeholder: (context, uri) => const Center(child: CircularProgressIndicator()), ), - placeholder: (context, uri) => const Center(child: CircularProgressIndicator()), ), ), ), @@ -162,7 +164,7 @@ class SessionView extends StatelessWidget { SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { - final user = session.sessionUsers[index % session.sessionUsers.length]; + final user = session.sessionUsers[index]; return ListTile( dense: true, title: Text( @@ -175,7 +177,7 @@ class SessionView extends StatelessWidget { ), ); }, - childCount: session.sessionUsers.length * 4, + childCount: session.sessionUsers.length, ), ) ], From 24178aaa437cf964d753ee2babe18f447ff7359f Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sat, 3 Jun 2023 17:17:54 +0200 Subject: [PATCH 2/4] Add tabbed view to homescreen --- lib/clients/messaging_client.dart | 2 + lib/main.dart | 179 ++++++--- lib/widgets/friends/friends_list.dart | 340 ++++-------------- lib/widgets/friends/friends_list_app_bar.dart | 223 ++++++++++++ lib/widgets/global_app_bar.dart | 20 ++ .../messages/message_session_invite.dart | 1 - lib/widgets/session_list.dart | 226 ++++++------ lib/widgets/session_list_app_bar.dart | 20 ++ lib/widgets/settings_app_bar.dart | 13 + lib/widgets/settings_page.dart | 15 +- 10 files changed, 576 insertions(+), 463 deletions(-) create mode 100644 lib/widgets/friends/friends_list_app_bar.dart create mode 100644 lib/widgets/global_app_bar.dart create mode 100644 lib/widgets/session_list_app_bar.dart create mode 100644 lib/widgets/settings_app_bar.dart 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/main.dart b/lib/main.dart index ef86464..8f3a027 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,7 +7,12 @@ import 'package:contacts_plus_plus/clients/messaging_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/session_list.dart'; +import 'package:contacts_plus_plus/widgets/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 +27,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 +55,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 +124,103 @@ 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 + 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, + showUpdateDialogOnFirstBuild(context); + final clientHolder = ClientHolder.of(context); + return _authData.isAuthenticated + ? Provider( + create: (context) => MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + ), + dispose: (context, value) => value.dispose(), + child: Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _appBars[_selectedPage], + ), ), - child: const FriendsList(), - ) : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); - } - }, - ); - } - ) - ), - ), - ); - } - ), + body: PageView( + controller: _pageController, + children: const [ + FriendsList(), + SessionList(), + SettingsPage(), + ], + ), + bottomNavigationBar: 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/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index b606d1c..4739cc7 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -1,28 +1,10 @@ -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/session_list.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,264 +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(); - }, - ); - }, - ), - MenuItemDefinition( - name: "Sessions", - icon: Icons.location_city, - onTap: () async { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const SessionList()), - ); - }, - ), - ].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 = ""; - }); - } - }, - ), - ), - ], - ), - ); - } + 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..020eff8 --- /dev/null +++ b/lib/widgets/friends/friends_list_app_bar.dart @@ -0,0 +1,223 @@ +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++"), + 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(), + ), + ) + ], + ), + ); + } + + @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/global_app_bar.dart b/lib/widgets/global_app_bar.dart new file mode 100644 index 0000000..74dbb15 --- /dev/null +++ b/lib/widgets/global_app_bar.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class GlobalAppBar extends StatefulWidget implements PreferredSizeWidget { + const GlobalAppBar({super.key}); + + @override + State createState() => _GlobalAppBarState(); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _GlobalAppBarState extends State { + @override + Widget build(BuildContext context) { + return AppBar( + + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/message_session_invite.dart b/lib/widgets/messages/message_session_invite.dart index 0eb0a56..ee52cb2 100644 --- a/lib/widgets/messages/message_session_invite.dart +++ b/lib/widgets/messages/message_session_invite.dart @@ -5,7 +5,6 @@ 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:flutter/material.dart'; diff --git a/lib/widgets/session_list.dart b/lib/widgets/session_list.dart index 3f343a8..157301b 100644 --- a/lib/widgets/session_list.dart +++ b/lib/widgets/session_list.dart @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.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'; @@ -14,146 +15,133 @@ class SessionList extends StatefulWidget { State createState() => _SessionListState(); } -class _SessionListState extends State { +class _SessionListState extends State with AutomaticKeepAliveClientMixin { Future>? _sessionsFuture; @override void didChangeDependencies() { super.didChangeDependencies(); - _sessionsFuture = SessionApi.getSessions(ClientHolder.of(context).apiClient); + _sessionsFuture ??= SessionApi.getSessions(ClientHolder.of(context).apiClient).then( + (value) => value.sorted( + (a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length), + ), + ); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - scrolledUnderElevation: 0, - title: const Text("Sessions"), - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Row( - children: [ - Expanded( - child: Container( - width: double.infinity, - height: 1, - color: Colors.black, + super.build(context); + return FutureBuilder>( + future: _sessionsFuture, + builder: (context, snapshot) { + final data = snapshot.data ?? []; + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: GridView.builder( + padding: const EdgeInsets.only(top: 10), + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), + itemCount: data.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 256, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + childAspectRatio: .8, ), - ), - ], - ), - ), - ), - body: FutureBuilder>( - future: _sessionsFuture, - builder: (context, snapshot) { - final data = snapshot.data ?? []; - return Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: GridView.builder( - padding: const EdgeInsets.only(top: 10), - physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), - 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), + itemBuilder: (context, index) { + final session = data[index]; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.outline, ), - 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, - ), + 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()), ), + 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, - ), + ), + 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), - ), - ), + ), + ], + ), + 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() - ], - ); - }, - ), + ), + if (snapshot.connectionState == ConnectionState.waiting) const LinearProgressIndicator() + ], + ); + }, ); } + + @override + bool get wantKeepAlive => true; } diff --git a/lib/widgets/session_list_app_bar.dart b/lib/widgets/session_list_app_bar.dart new file mode 100644 index 0000000..3175049 --- /dev/null +++ b/lib/widgets/session_list_app_bar.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class SessionListAppBar extends StatelessWidget { + const SessionListAppBar({super.key}); + + @override + Widget build(BuildContext context) { + return AppBar( + title: const Text("Sessions"), + actions: [ + IconButton( + onPressed: () { + + }, + icon: const Icon(Icons.filter_alt_outlined), + ) + ], + ); + } +} diff --git a/lib/widgets/settings_app_bar.dart b/lib/widgets/settings_app_bar.dart new file mode 100644 index 0000000..5433939 --- /dev/null +++ b/lib/widgets/settings_app_bar.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class SettingsAppBar extends StatelessWidget { + const SettingsAppBar({super.key}); + + @override + Widget build(BuildContext context) { + return AppBar( + title: const Text("Settings"), + ); + } + +} \ 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 { }, ) ], - ), - ); + ); } } From 65207e6e90bcbddf7443e4206120a8333d2a3331 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 4 Jun 2023 16:27:18 +0200 Subject: [PATCH 3/4] Add filter function to session list --- lib/apis/session_api.dart | 4 +- lib/clients/session_client.dart | 35 ++++ lib/main.dart | 124 +++++++----- lib/models/session.dart | 85 +++++++- lib/widgets/friends/friends_list_app_bar.dart | 8 + lib/widgets/global_app_bar.dart | 20 -- lib/widgets/login_screen.dart | 1 - .../messages/message_session_invite.dart | 2 +- .../messages/messages_session_header.dart | 2 +- lib/widgets/session_list.dart | 147 -------------- lib/widgets/session_list_app_bar.dart | 20 -- .../sessions/session_filter_dialog.dart | 190 ++++++++++++++++++ lib/widgets/sessions/session_list.dart | 161 +++++++++++++++ .../sessions/session_list_app_bar.dart | 48 +++++ lib/widgets/{ => sessions}/session_view.dart | 0 lib/widgets/settings_app_bar.dart | 8 + 16 files changed, 599 insertions(+), 256 deletions(-) create mode 100644 lib/clients/session_client.dart delete mode 100644 lib/widgets/global_app_bar.dart delete mode 100644 lib/widgets/session_list.dart delete mode 100644 lib/widgets/session_list_app_bar.dart create mode 100644 lib/widgets/sessions/session_filter_dialog.dart create mode 100644 lib/widgets/sessions/session_list.dart create mode 100644 lib/widgets/sessions/session_list_app_bar.dart rename lib/widgets/{ => sessions}/session_view.dart (100%) diff --git a/lib/apis/session_api.dart b/lib/apis/session_api.dart index 952e45d..a453fbd 100644 --- a/lib/apis/session_api.dart +++ b/lib/apis/session_api.dart @@ -11,8 +11,8 @@ class SessionApi { return Session.fromMap(body); } - static Future> getSessions(ApiClient client) async { - final response = await client.get("/sessions"); + 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(); 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 8f3a027..dee27e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,13 +4,14 @@ 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/session_list.dart'; -import 'package:contacts_plus_plus/widgets/session_list_app_bar.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'; @@ -136,32 +137,41 @@ class _ContactsPlusPlusState extends State { }, 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) { + 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 - ? Provider( - create: (context) => MessagingClient( - apiClient: clientHolder.apiClient, - notificationClient: clientHolder.notificationClient, - ), - dispose: (context, value) => value.dispose(), + ? MultiProvider( + providers: [ + Provider( + create: (context) => MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + ), + dispose: (context, value) => value.dispose(), + ), + Provider( + create: (context) => SessionClient( + apiClient: clientHolder.apiClient, + ), + ), + ], child: Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), @@ -178,33 +188,39 @@ class _ContactsPlusPlusState extends State { SettingsPage(), ], ), - bottomNavigationBar: 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", - ), - ], + 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", + ), + ], + ), ), ), ) @@ -217,7 +233,9 @@ class _ContactsPlusPlusState extends State { } }, ); - })), + }, + ), + ), ), ); }), diff --git a/lib/models/session.dart b/lib/models/session.dart index a2c428d..03aa1c3 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -1,3 +1,5 @@ +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/string_formatter.dart'; class Session { @@ -17,11 +19,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 +50,7 @@ class Session { headlessHost: false, hostUserId: "", hostUsername: "", - accessLevel: SessionAccessLevel.unknown - ); + accessLevel: SessionAccessLevel.unknown); } bool get isNone => id.isEmpty && isValid == false; @@ -62,7 +74,7 @@ class Session { ); } - Map toMap({bool shallow=false}) { + Map toMap({bool shallow = false}) { return { "sessionId": id, "name": name, @@ -80,8 +92,6 @@ class Session { }; } - - bool get isLive => !hasEnded && isValid; } @@ -101,7 +111,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 +147,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_app_bar.dart b/lib/widgets/friends/friends_list_app_bar.dart index 020eff8..07b2ab1 100644 --- a/lib/widgets/friends/friends_list_app_bar.dart +++ b/lib/widgets/friends/friends_list_app_bar.dart @@ -52,6 +52,7 @@ class _FriendsListAppBarState extends State with AutomaticKee value: Provider.of(context, listen: false), child: AppBar( title: const Text("Contacts++"), + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, actions: [ FutureBuilder( future: _userStatusFuture, @@ -206,6 +207,13 @@ class _FriendsListAppBarState extends State with AutomaticKee ), ) ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + height: 1, + color: Colors.black, + ), + ), ), ); } diff --git a/lib/widgets/global_app_bar.dart b/lib/widgets/global_app_bar.dart deleted file mode 100644 index 74dbb15..0000000 --- a/lib/widgets/global_app_bar.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -class GlobalAppBar extends StatefulWidget implements PreferredSizeWidget { - const GlobalAppBar({super.key}); - - @override - State createState() => _GlobalAppBarState(); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} - -class _GlobalAppBarState extends State { - @override - Widget build(BuildContext context) { - return AppBar( - - ); - } -} \ No newline at end of file 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 ee52cb2..6754bb7 100644 --- a/lib/widgets/messages/message_session_invite.dart +++ b/lib/widgets/messages/message_session_invite.dart @@ -6,7 +6,7 @@ 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/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_list.dart b/lib/widgets/session_list.dart deleted file mode 100644 index 157301b..0000000 --- a/lib/widgets/session_list.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:collection/collection.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/session_view.dart'; -import 'package:flutter/material.dart'; - -class SessionList extends StatefulWidget { - const SessionList({super.key}); - - @override - State createState() => _SessionListState(); -} - -class _SessionListState extends State with AutomaticKeepAliveClientMixin { - Future>? _sessionsFuture; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _sessionsFuture ??= SessionApi.getSessions(ClientHolder.of(context).apiClient).then( - (value) => value.sorted( - (a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length), - ), - ); - } - - @override - Widget build(BuildContext context) { - super.build(context); - return FutureBuilder>( - future: _sessionsFuture, - builder: (context, snapshot) { - final data = snapshot.data ?? []; - return Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: GridView.builder( - padding: const EdgeInsets.only(top: 10), - physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), - 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/session_list_app_bar.dart b/lib/widgets/session_list_app_bar.dart deleted file mode 100644 index 3175049..0000000 --- a/lib/widgets/session_list_app_bar.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -class SessionListAppBar extends StatelessWidget { - const SessionListAppBar({super.key}); - - @override - Widget build(BuildContext context) { - return AppBar( - title: const Text("Sessions"), - actions: [ - IconButton( - onPressed: () { - - }, - icon: const Icon(Icons.filter_alt_outlined), - ) - ], - ); - } -} 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/session_view.dart b/lib/widgets/sessions/session_view.dart similarity index 100% rename from lib/widgets/session_view.dart rename to lib/widgets/sessions/session_view.dart diff --git a/lib/widgets/settings_app_bar.dart b/lib/widgets/settings_app_bar.dart index 5433939..1b226bf 100644 --- a/lib/widgets/settings_app_bar.dart +++ b/lib/widgets/settings_app_bar.dart @@ -6,7 +6,15 @@ class SettingsAppBar extends StatelessWidget { @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, + ), + ), ); } From dfa1c6e77ac58f2ff1f4a6af7b3993676a623a30 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 4 Jun 2023 16:51:14 +0200 Subject: [PATCH 4/4] Move session thumbnail out of flexible sliver --- lib/models/session.dart | 1 - lib/widgets/sessions/session_view.dart | 293 +++++++++++++------------ 2 files changed, 155 insertions(+), 139 deletions(-) diff --git a/lib/models/session.dart b/lib/models/session.dart index 03aa1c3..4febf70 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -1,4 +1,3 @@ -import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/string_formatter.dart'; diff --git a/lib/widgets/sessions/session_view.dart b/lib/widgets/sessions/session_view.dart index cc053aa..a461a3c 100644 --- a/lib/widgets/sessions/session_view.dart +++ b/lib/widgets/sessions/session_view.dart @@ -1,23 +1,40 @@ 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 StatelessWidget { +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 Scaffold( - body: CustomScrollView( - physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), - slivers: [ - SliverAppBar( + 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, @@ -26,12 +43,6 @@ class SessionView extends StatelessWidget { 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, @@ -42,146 +53,152 @@ class SessionView extends StatelessWidget { child: Row( children: [ Expanded( - child: Container( - width: double.infinity, - height: 1, - color: Colors.black, - )), + child: Container( + width: double.infinity, + height: 1, + color: Colors.black, + ), + ), ], ), ), - flexibleSpace: FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: 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), - ), + ), + 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, ), - ); - }, - 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, ), ), - ); - }, - 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(), + 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, ), - ], - ), - ), - 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, + ), + 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, + ), + ], ), - Text( - session.headlessHost ? "Yes" : "No", - 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: + ), + ListSectionHeader( + leadingText: "Users", + trailingText: "${session.sessionUsers.length.toString().padLeft(2, "0")}/${session.maxUsers.toString().padLeft(2, "0")}", - showLine: false, + 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(), ), ), - SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - final user = session.sessionUsers[index]; - return ListTile( - dense: true, - title: Text( - user.username, - textAlign: TextAlign.start, - ), - subtitle: Text( - user.isPresent ? "Active" : "Inactive", - textAlign: TextAlign.start, - ), - ); - }, - childCount: session.sessionUsers.length, - ), - ) - ], - ), + ); + }, + ); } }