Merge pull request #23 from Nutcake/session-list

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

View file

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

View file

@ -68,6 +68,7 @@ class MessagingClient extends ChangeNotifier {
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
: _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();

View file

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

View file

@ -4,10 +4,16 @@ import 'package:contacts_plus_plus/apis/github_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/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<ContactsPlusPlus> {
static const List<Widget> _appBars = [
FriendsListAppBar(
key: ValueKey("friends_list_app_bar"),
),
SessionListAppBar(
key: ValueKey("session_list_app_bar"),
),
SettingsAppBar(
key: ValueKey("settings_app_bar"),
)
];
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
final 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,8 +125,7 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
@override
Widget build(BuildContext context) {
return Phoenix(
child: Builder(
builder: (context) {
child: Builder(builder: (context) {
return ClientHolder(
settingsClient: widget.settingsClient,
authenticationData: _authData,
@ -121,7 +142,8 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
theme: ThemeData(
useMaterial3: true,
textTheme: _typography.black,
colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light),
colorScheme:
lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light),
),
darkTheme: ThemeData(
useMaterial3: true,
@ -129,20 +151,80 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
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
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(
return _authData.isAuthenticated
? MultiProvider(
providers: [
Provider(
create: (context) => MessagingClient(
apiClient: clientHolder.apiClient,
notificationClient: clientHolder.notificationClient,
),
child: const FriendsList(),
) :
LoginScreen(
dispose: (context, value) => value.dispose(),
),
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(() {
@ -151,13 +233,12 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
}
},
);
}
)
},
),
),
),
);
}
),
}),
);
}
}

View file

@ -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,
);
}
@ -137,3 +147,55 @@ class SessionUser {
};
}
}
class SessionFilterSettings {
final String name;
final bool includeEnded;
final bool includeIncompatible;
final String hostName;
final int minActiveUsers;
final bool includeEmptyHeadless;
const SessionFilterSettings({
required this.name,
required this.includeEnded,
required this.includeIncompatible,
required this.hostName,
required this.minActiveUsers,
required this.includeEmptyHeadless,
});
factory SessionFilterSettings.empty() => const SessionFilterSettings(
name: "",
includeEnded: false,
includeIncompatible: false,
hostName: "",
minActiveUsers: 0,
includeEmptyHeadless: true,
);
String buildRequestString() => "?includeEmptyHeadless=$includeEmptyHeadless"
"${"&includeEnded=$includeEnded"}"
"${name.isNotEmpty ? "&name=$name" : ""}"
"${!includeIncompatible ? "&compatibilityHash=${Uri.encodeComponent(Config.latestCompatHash)}" : ""}"
"${hostName.isNotEmpty ? "&hostName=$hostName" : ""}"
"${minActiveUsers > 0 ? "&minActiveUsers=$minActiveUsers" : ""}";
SessionFilterSettings copyWith({
String? name,
bool? includeEnded,
bool? includeIncompatible,
String? hostName,
int? minActiveUsers,
bool? includeEmptyHeadless,
}) {
return SessionFilterSettings(
name: name ?? this.name,
includeEnded: includeEnded ?? this.includeEnded,
includeIncompatible: includeIncompatible ?? this.includeIncompatible,
hostName: hostName ?? this.hostName,
minActiveUsers: minActiveUsers ?? this.minActiveUsers,
includeEmptyHeadless: includeEmptyHeadless ?? this.includeEmptyHeadless,
);
}
}

View file

@ -1,29 +1,11 @@
import 'dart:async';
import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/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,204 +13,18 @@ class FriendsList extends StatefulWidget {
State<FriendsList> createState() => _FriendsListState();
}
class _FriendsListState extends State<FriendsList> {
Future<UserStatus>? _userStatusFuture;
ClientHolder? _clientHolder;
class _FriendsListState extends State<FriendsList> 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;
});
}
@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<OnlineStatus>(
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color(context),),
),
Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"),
],
),
onSelected: (OnlineStatus onlineStatus) async {
try {
final newStatus = userStatus.copyWith(onlineStatus: onlineStatus);
setState(() {
_userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now()));
});
final settingsClient = ClientHolder
.of(context)
.settingsClient;
await UserApi.setStatus(clientHolder.apiClient, status: newStatus);
await settingsClient.changeSettings(
settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
} catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(
"Failed to set online-status.")));
setState(() {
_userStatusFuture = Future.value(userStatus);
});
}
},
itemBuilder: (BuildContext context) =>
OnlineStatus.values.where((element) =>
element == OnlineStatus.online
|| element == OnlineStatus.invisible).map((item) =>
PopupMenuItem<OnlineStatus>(
value: item,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.circle, size: 16, color: item.color(context),),
const SizedBox(width: 8,),
Text(toBeginningOfSentenceCase(item.name)!),
],
),
),
).toList());
} else if (snapshot.hasError) {
return TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Theme
.of(context)
.colorScheme
.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2)
),
onPressed: () {
setState(() {
_userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient
.userId);
});
},
icon: const Icon(Icons.warning),
label: const Text("Retry"),
);
} else {
return TextButton.icon(
style: TextButton.styleFrom(
disabledForegroundColor: Theme
.of(context)
.colorScheme
.onSurface,
),
onPressed: null,
icon: Container(
width: 16,
height: 16,
margin: const EdgeInsets.only(right: 4),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme
.of(context)
.colorScheme
.onSurface,
),
),
label: const Text("Loading"),
);
}
}
),
Padding(
padding: const EdgeInsets.only(left: 4, right: 4),
child: PopupMenuButton<MenuItemDefinition>(
icon: const Icon(Icons.more_vert),
onSelected: (MenuItemDefinition itemDef) async {
await itemDef.onTap();
},
itemBuilder: (BuildContext context) =>
[
MenuItemDefinition(
name: "Settings",
icon: Icons.settings,
onTap: () async {
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
},
),
MenuItemDefinition(
name: "Find Users",
icon: Icons.person_add,
onTap: () async {
final mClient = Provider.of<MessagingClient>(context, listen: false);
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
ChangeNotifierProvider<MessagingClient>.value(
value: mClient,
child: const UserSearch(),
),
),
);
},
),
MenuItemDefinition(
name: "My Profile",
icon: Icons.person,
onTap: () async {
await showDialog(
context: context,
builder: (context) {
return const MyProfileDialog();
},
);
},
),
].map((item) =>
PopupMenuItem<MenuItemDefinition>(
value: item,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name),
Icon(item.icon),
],
),
),
).toList(),
),
)
],
),
body: Stack(
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<MessagingClient>(context, listen: false),
child: Stack(
alignment: Alignment.topCenter,
children: [
Consumer<MessagingClient>(
builder: (context, mClient, _) {
Consumer<MessagingClient>(builder: (context, mClient, _) {
if (mClient.initStatus == null) {
return const LinearProgressIndicator();
} else if (mClient.initStatus!.isNotEmpty) {
@ -248,8 +44,9 @@ class _FriendsListState extends State<FriendsList> {
} else {
var friends = List.from(mClient.cachedFriends); // Explicit copy.
if (_searchFilter.isNotEmpty) {
friends = friends.where((element) =>
element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList();
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(
@ -265,8 +62,7 @@ class _FriendsListState extends State<FriendsList> {
},
);
}
}
),
}),
Align(
alignment: Alignment.bottomCenter,
child: ExpandingInputFab(
@ -288,4 +84,7 @@ class _FriendsListState extends State<FriendsList> {
),
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,231 @@
import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/users/online_status.dart';
import 'package:contacts_plus_plus/models/users/user_status.dart';
import 'package:contacts_plus_plus/widgets/friends/user_search.dart';
import 'package:contacts_plus_plus/widgets/my_profile_dialog.dart';
import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class FriendsListAppBar extends StatefulWidget {
const FriendsListAppBar({super.key});
@override
State<StatefulWidget> createState() => _FriendsListAppBarState();
}
class _FriendsListAppBarState extends State<FriendsListAppBar> with AutomaticKeepAliveClientMixin {
Future<UserStatus>? _userStatusFuture;
ClientHolder? _clientHolder;
@override
void didChangeDependencies() async {
super.didChangeDependencies();
final clientHolder = ClientHolder.of(context);
if (_clientHolder != clientHolder) {
_clientHolder = clientHolder;
_refreshUserStatus();
}
}
void _refreshUserStatus() {
final apiClient = _clientHolder!.apiClient;
_userStatusFuture ??= UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async {
if (value.onlineStatus == OnlineStatus.offline) {
final newStatus = value.copyWith(
onlineStatus:
OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus.valueOrDefault]);
await UserApi.setStatus(apiClient, status: newStatus);
return newStatus;
}
return value;
});
}
@override
Widget build(BuildContext context) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<MessagingClient>(context, listen: false),
child: AppBar(
title: const Text("Contacts++"),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
actions: [
FutureBuilder(
future: _userStatusFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
final userStatus = snapshot.data as UserStatus;
return PopupMenuButton<OnlineStatus>(
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(
Icons.circle,
size: 16,
color: userStatus.onlineStatus.color(context),
),
),
Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"),
],
),
onSelected: (OnlineStatus onlineStatus) async {
try {
final newStatus = userStatus.copyWith(onlineStatus: onlineStatus);
setState(() {
_userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now()));
});
final settingsClient = _clientHolder!.settingsClient;
await UserApi.setStatus(_clientHolder!.apiClient, status: newStatus);
await settingsClient.changeSettings(
settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
} catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("Failed to set online-status.")));
setState(() {
_userStatusFuture = Future.value(userStatus);
});
}
},
itemBuilder: (BuildContext context) => OnlineStatus.values
.where((element) => element == OnlineStatus.online || element == OnlineStatus.invisible)
.map(
(item) => PopupMenuItem<OnlineStatus>(
value: item,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(
Icons.circle,
size: 16,
color: item.color(context),
),
const SizedBox(
width: 8,
),
Text(toBeginningOfSentenceCase(item.name)!),
],
),
),
)
.toList());
} else if (snapshot.hasError) {
return TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2)),
onPressed: () {
setState(() {
_userStatusFuture =
UserApi.getUserStatus(_clientHolder!.apiClient, userId: _clientHolder!.apiClient.userId);
});
},
icon: const Icon(Icons.warning),
label: const Text("Retry"),
);
} else {
return TextButton.icon(
style: TextButton.styleFrom(
disabledForegroundColor: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
icon: Container(
width: 16,
height: 16,
margin: const EdgeInsets.only(right: 4),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onSurface,
),
),
label: const Text("Loading"),
);
}
},
),
Padding(
padding: const EdgeInsets.only(left: 4, right: 4),
child: PopupMenuButton<MenuItemDefinition>(
icon: const Icon(Icons.more_vert),
onSelected: (MenuItemDefinition itemDef) async {
await itemDef.onTap();
},
itemBuilder: (BuildContext context) => [
MenuItemDefinition(
name: "Settings",
icon: Icons.settings,
onTap: () async {
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
},
),
MenuItemDefinition(
name: "Find Users",
icon: Icons.person_add,
onTap: () async {
final mClient = Provider.of<MessagingClient>(context, listen: false);
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider<MessagingClient>.value(
value: mClient,
child: const UserSearch(),
),
),
);
},
),
MenuItemDefinition(
name: "My Profile",
icon: Icons.person,
onTap: () async {
await showDialog(
context: context,
builder: (context) {
return const MyProfileDialog();
},
);
},
),
]
.map(
(item) => PopupMenuItem<MenuItemDefinition>(
value: item,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name),
Icon(item.icon),
],
),
),
)
.toList(),
),
)
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: Colors.black,
),
),
),
);
}
@override
bool get wantKeepAlive => true;
}
class MenuItemDefinition {
final String name;
final IconData icon;
final Function() onTap;
const MenuItemDefinition({required this.name, required this.icon, required this.onTap});
}

View file

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

View file

@ -5,9 +5,8 @@ import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/models/session.dart';
import 'package:contacts_plus_plus/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 {

View file

@ -3,7 +3,7 @@ import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/models/session.dart';
import 'package:contacts_plus_plus/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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,17 +11,7 @@ class SettingsPage extends StatelessWidget {
@override
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,7 +100,6 @@ class SettingsPage extends StatelessWidget {
},
)
],
),
);
}
}