Add caching to session filter and remove some redundant providers

This commit is contained in:
Nutcake 2023-07-11 18:44:20 +02:00
parent 56350ea2c7
commit e87521df9d
11 changed files with 639 additions and 590 deletions

View file

@ -1,17 +1,28 @@
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/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/session.dart';
import 'package:flutter/foundation.dart';
class SessionClient extends ChangeNotifier {
final ApiClient apiClient;
final SettingsClient settingsClient;
Future<List<Session>>? _sessionsFuture;
SessionFilterSettings _filterSettings = SessionFilterSettings.empty();
SessionClient({required this.apiClient});
SessionClient({required this.apiClient, required this.settingsClient}) {
_filterSettings = SessionFilterSettings(
name: "",
hostName: "",
includeEnded: settingsClient.currentSettings.sessionViewLastIncludeEnded.valueOrDefault,
includeIncompatible: settingsClient.currentSettings.sessionViewLastIncludeIncompatible.valueOrDefault,
minActiveUsers: settingsClient.currentSettings.sessionViewLastMinimumUsers.valueOrDefault,
includeEmptyHeadless: settingsClient.currentSettings.sessionViewLastIncludeEmpty.valueOrDefault,
);
}
SessionFilterSettings get filterSettings => _filterSettings;
@ -22,12 +33,16 @@ class SessionClient extends ChangeNotifier {
reloadSessions();
}
void reloadSessions() {
void initSessions() {
_sessionsFuture = SessionApi.getSessions(apiClient, filterSettings: _filterSettings).then(
(value) => value.sorted(
(a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length),
(value) => value.sorted(
(a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length),
),
);
}
void reloadSessions() {
initSessions();
notifyListeners();
}
}

View file

@ -8,17 +8,12 @@ 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_app_bar.dart';
import 'package:contacts_plus_plus/widgets/homepage.dart';
import 'package:contacts_plus_plus/widgets/inventory/inventory_browser_app_bar.dart';
import 'package:contacts_plus_plus/widgets/login_screen.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/update_notifier.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:hive_flutter/hive_flutter.dart';
@ -35,8 +30,6 @@ void main() async {
debug: kDebugMode,
);
Provider.debugCheckInvalidValueType = null;
await Hive.initFlutter();
final dateFormat = DateFormat.Hms();
@ -156,19 +149,19 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
return _authData.isAuthenticated
? MultiProvider(
providers: [
Provider(
ChangeNotifierProvider(
create: (context) => MessagingClient(
apiClient: clientHolder.apiClient,
notificationClient: clientHolder.notificationClient,
),
dispose: (context, value) => value.dispose(),
),
Provider(
ChangeNotifierProvider(
create: (context) => SessionClient(
apiClient: clientHolder.apiClient,
settingsClient: clientHolder.settingsClient,
),
),
Provider(
ChangeNotifierProvider(
create: (context) => InventoryClient(
apiClient: clientHolder.apiClient,
),

View file

@ -40,31 +40,47 @@ class Settings {
final SettingsEntry<String> lastDismissedVersion;
final SettingsEntry<String> machineId;
final SettingsEntry<int> themeMode;
final SettingsEntry<int> sessionViewLastMinimumUsers;
final SettingsEntry<bool> sessionViewLastIncludeEnded;
final SettingsEntry<bool> sessionViewLastIncludeEmpty;
final SettingsEntry<bool> sessionViewLastIncludeIncompatible;
Settings({
SettingsEntry<bool>? notificationsDenied,
SettingsEntry<int>? lastOnlineStatus,
SettingsEntry<int>? themeMode,
SettingsEntry<String>? lastDismissedVersion,
SettingsEntry<String>? machineId
})
: notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
SettingsEntry<String>? machineId,
SettingsEntry<int>? sessionViewLastMinimumUsers,
SettingsEntry<bool>? sessionViewLastIncludeEnded,
SettingsEntry<bool>? sessionViewLastIncludeEmpty,
SettingsEntry<bool>? sessionViewLastIncludeIncompatible,
}) : notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index),
themeMode = themeMode ?? SettingsEntry<int>(deflt: ThemeMode.dark.index),
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString()),
machineId = machineId ?? SettingsEntry<String>(deflt: const Uuid().v4());
machineId = machineId ?? SettingsEntry<String>(deflt: const Uuid().v4()),
sessionViewLastMinimumUsers = sessionViewLastMinimumUsers ?? const SettingsEntry<int>(deflt: 0),
sessionViewLastIncludeEnded = sessionViewLastIncludeEnded ?? const SettingsEntry<bool>(deflt: false),
sessionViewLastIncludeEmpty = sessionViewLastIncludeEmpty ?? const SettingsEntry<bool>(deflt: true),
sessionViewLastIncludeIncompatible =
sessionViewLastIncludeIncompatible ?? const SettingsEntry<bool>(deflt: false);
factory Settings.fromMap(Map map) {
return Settings(
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]),
lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]),
themeMode: retrieveEntryOrNull<int>(map["themeMode"]),
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"]),
machineId: retrieveEntryOrNull<String>(map["machineId"]),
notificationsDenied: getEntryOrNull<bool>(map["notificationsDenied"]),
lastOnlineStatus: getEntryOrNull<int>(map["lastOnlineStatus"]),
themeMode: getEntryOrNull<int>(map["themeMode"]),
lastDismissedVersion: getEntryOrNull<String>(map["lastDismissedVersion"]),
machineId: getEntryOrNull<String>(map["machineId"]),
sessionViewLastMinimumUsers: getEntryOrNull<int>(map["sessionViewLastMinimumUsers"]),
sessionViewLastIncludeEnded: getEntryOrNull<bool>(map["sessionViewLastIncludeEnded"]),
sessionViewLastIncludeEmpty: getEntryOrNull<bool>(map["sessionViewLastIncludeEmpty"]),
sessionViewLastIncludeIncompatible: getEntryOrNull<bool>(map["sessionViewLastIncludeIncompatible"]),
);
}
static SettingsEntry<T>? retrieveEntryOrNull<T>(Map? map) {
static SettingsEntry<T>? getEntryOrNull<T>(Map? map) {
if (map == null) return null;
try {
return SettingsEntry<T>.fromMap(map);
@ -80,6 +96,10 @@ class Settings {
"themeMode": themeMode.toMap(),
"lastDismissedVersion": lastDismissedVersion.toMap(),
"machineId": machineId.toMap(),
"sessionViewLastMinimumUsers": sessionViewLastMinimumUsers.toMap(),
"sessionViewLastIncludeEnded": sessionViewLastIncludeEnded.toMap(),
"sessionViewLastIncludeEmpty": sessionViewLastIncludeEmpty.toMap(),
"sessionViewLastIncludeIncompatible": sessionViewLastIncludeIncompatible.toMap(),
};
}
@ -91,6 +111,10 @@ class Settings {
int? themeMode,
String? lastDismissedVersion,
String? machineId,
int? sessionViewLastMinimumUsers,
bool? sessionViewLastIncludeEnded,
bool? sessionViewLastIncludeEmpty,
bool? sessionViewLastIncludeIncompatible,
}) {
return Settings(
notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied),
@ -98,6 +122,11 @@ class Settings {
themeMode: this.themeMode.passThrough(themeMode),
lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion),
machineId: this.machineId.passThrough(machineId),
sessionViewLastMinimumUsers: this.sessionViewLastMinimumUsers.passThrough(sessionViewLastMinimumUsers),
sessionViewLastIncludeEnded: this.sessionViewLastIncludeEnded.passThrough(sessionViewLastIncludeEnded),
sessionViewLastIncludeEmpty: this.sessionViewLastIncludeEmpty.passThrough(sessionViewLastIncludeEmpty),
sessionViewLastIncludeIncompatible:
this.sessionViewLastIncludeIncompatible.passThrough(sessionViewLastIncludeIncompatible),
);
}
}

View file

@ -5,7 +5,6 @@ import 'package:contacts_plus_plus/widgets/friends/friend_list_tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class FriendsList extends StatefulWidget {
const FriendsList({super.key});
@ -19,50 +18,52 @@ class _FriendsListState extends State<FriendsList> with AutomaticKeepAliveClient
@override
Widget build(BuildContext context) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<MessagingClient>(context, listen: false),
child: Stack(
return Consumer<MessagingClient>(
builder: (context, mClient, _) {
return Stack(
alignment: Alignment.topCenter,
children: [
Consumer<MessagingClient>(builder: (context, mClient, _) {
if (mClient.initStatus == null) {
return const LinearProgressIndicator();
} else if (mClient.initStatus!.isNotEmpty) {
return Column(
children: [
Expanded(
child: DefaultErrorWidget(
message: mClient.initStatus,
onRetry: () async {
mClient.resetInitStatus();
mClient.refreshFriendsListWithErrorHandler();
},
Builder(
builder: (context) {
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));
],
);
} 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,
);
},
);
}
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(
@ -81,7 +82,8 @@ class _FriendsListState extends State<FriendsList> with AutomaticKeepAliveClient
),
),
],
),
);
},
);
}

View file

@ -47,158 +47,155 @@ class _FriendsListAppBarState extends State<FriendsListAppBar> with AutomaticKee
@override
Widget build(BuildContext context) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<MessagingClient>(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<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: "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,
return AppBar(
title: const Text("Contacts++"),
actions: [
FutureBuilder(
future: _userStatusFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
final userStatus = snapshot.data as UserStatus;
return PopupMenuButton<OnlineStatus>(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name),
Icon(item.icon),
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,
),
)
.toList(),
),
)
],
),
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: "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(),
),
)
],
);
}

View file

@ -36,196 +36,193 @@ class _InventoryBrowserState extends State<InventoryBrowser> with AutomaticKeepA
@override
Widget build(BuildContext context) {
super.build(context);
return ChangeNotifierProvider.value(
value: Provider.of<InventoryClient>(context),
child: Consumer<InventoryClient>(builder: (BuildContext context, InventoryClient iClient, Widget? child) {
return FutureBuilder<NeosDirectory>(
future: iClient.directoryFuture,
builder: (context, snapshot) {
final currentDir = snapshot.data;
return WillPopScope(
onWillPop: () async {
// Allow pop when at root or not loaded
if (currentDir?.isRoot ?? true) {
return true;
return Consumer<InventoryClient>(builder: (BuildContext context, InventoryClient iClient, Widget? child) {
return FutureBuilder<NeosDirectory>(
future: iClient.directoryFuture,
builder: (context, snapshot) {
final currentDir = snapshot.data;
return WillPopScope(
onWillPop: () async {
// Allow pop when at root or not loaded
if (currentDir?.isRoot ?? true) {
return true;
}
iClient.navigateUp();
return false;
},
child: RefreshIndicator(
onRefresh: () async {
if (_refreshLimiter?.isActive ?? false) return;
try {
await iClient.reloadCurrentDirectory();
_refreshLimiter = Timer(_refreshLimit, () {});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e")));
}
iClient.navigateUp();
return false;
},
child: RefreshIndicator(
onRefresh: () async {
if (_refreshLimiter?.isActive ?? false) return;
try {
await iClient.reloadCurrentDirectory();
_refreshLimiter = Timer(_refreshLimit, () {});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e")));
child: Builder(
builder: (context) {
if (snapshot.hasError) {
FlutterError.reportError(
FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return DefaultErrorWidget(
message: snapshot.error.toString(),
onRetry: () {
iClient.loadInventoryRoot();
iClient.forceNotify();
},
);
}
},
child: Builder(
builder: (context) {
if (snapshot.hasError) {
FlutterError.reportError(
FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return DefaultErrorWidget(
message: snapshot.error.toString(),
onRetry: () {
iClient.loadInventoryRoot();
iClient.forceNotify();
},
);
}
final directory = snapshot.data;
final records = directory?.records ?? [];
final directory = snapshot.data;
final records = directory?.records ?? [];
records.sort((a, b) => a.name.compareTo(b.name));
final paths = records
.where((element) =>
element.recordType == RecordType.link || element.recordType == RecordType.directory)
.toList();
final objects = records
.where((element) =>
element.recordType != RecordType.link && element.recordType != RecordType.directory)
.toList();
final pathSegments = directory?.absolutePathSegments ?? [];
return Stack(
children: [
ListView(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Wrap(
children: pathSegments
.mapIndexed(
(idx, segment) => Row(
mainAxisSize: MainAxisSize.min,
children: [
if (idx != 0) const Icon(Icons.chevron_right),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: idx == pathSegments.length - 1
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
onPressed: () {
iClient.navigateUp(times: pathSegments.length - 1 - idx);
},
child: Text(segment),
records.sort((a, b) => a.name.compareTo(b.name));
final paths = records
.where((element) =>
element.recordType == RecordType.link || element.recordType == RecordType.directory)
.toList();
final objects = records
.where((element) =>
element.recordType != RecordType.link && element.recordType != RecordType.directory)
.toList();
final pathSegments = directory?.absolutePathSegments ?? [];
return Stack(
children: [
ListView(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Wrap(
children: pathSegments
.mapIndexed(
(idx, segment) => Row(
mainAxisSize: MainAxisSize.min,
children: [
if (idx != 0) const Icon(Icons.chevron_right),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: idx == pathSegments.length - 1
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
onPressed: () {
iClient.navigateUp(times: pathSegments.length - 1 - idx);
},
child: Text(segment),
),
),
],
),
)
.toList(),
),
),
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: paths.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 256,
childAspectRatio: 3.5,
crossAxisSpacing: 0,
mainAxisSpacing: 0),
itemBuilder: (context, index) {
final record = paths[index];
return PathInventoryTile(
record: record,
onTap: iClient.isAnyRecordSelected
? () {}
: () async {
try {
await iClient.navigateTo(record);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to open directory: $e")),
);
}
}
},
);
},
),
const SizedBox(
height: 0,
),
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: objects.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 256,
childAspectRatio: 1,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
itemBuilder: (context, index) {
final record = objects[index];
return ObjectInventoryTile(
record: record,
selected: iClient.isRecordSelected(record),
onTap: iClient.isAnyRecordSelected
? () async {
iClient.toggleRecordSelected(record);
}
: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoView(
minScale: PhotoViewComputedScale.contained,
imageProvider:
CachedNetworkImageProvider(Aux.neosDbToHttp(record.thumbnailUri)),
heroAttributes: PhotoViewHeroAttributes(tag: record.id),
),
),
],
),
)
.toList(),
),
),
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: paths.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 256,
childAspectRatio: 3.5,
crossAxisSpacing: 0,
mainAxisSpacing: 0),
itemBuilder: (context, index) {
final record = paths[index];
return PathInventoryTile(
record: record,
onTap: iClient.isAnyRecordSelected
? () {}
: () async {
try {
await iClient.navigateTo(record);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to open directory: $e")),
);
}
}
},
);
},
),
const SizedBox(
height: 0,
),
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: objects.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 256,
childAspectRatio: 1,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
itemBuilder: (context, index) {
final record = objects[index];
return ObjectInventoryTile(
record: record,
selected: iClient.isRecordSelected(record),
onTap: iClient.isAnyRecordSelected
? () async {
iClient.toggleRecordSelected(record);
}
: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoView(
minScale: PhotoViewComputedScale.contained,
imageProvider:
CachedNetworkImageProvider(Aux.neosDbToHttp(record.thumbnailUri)),
heroAttributes: PhotoViewHeroAttributes(tag: record.id),
),
),
);
},
onLongPress: () async {
iClient.toggleRecordSelected(record);
},
);
},
),
],
),
Align(
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: snapshot.connectionState == ConnectionState.waiting
? const LinearProgressIndicator()
: null,
);
},
onLongPress: () async {
iClient.toggleRecordSelected(record);
},
);
},
),
],
),
Align(
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: snapshot.connectionState == ConnectionState.waiting
? const LinearProgressIndicator()
: null,
),
Align(
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: snapshot.connectionState == ConnectionState.waiting
? Container(
width: double.infinity,
height: double.infinity,
color: Colors.black38,
)
: null,
),
)
],
);
},
),
),
Align(
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: snapshot.connectionState == ConnectionState.waiting
? Container(
width: double.infinity,
height: double.infinity,
color: Colors.black38,
)
: null,
),
)
],
);
},
),
);
});
}),
);
),
);
});
});
}
@override

View file

@ -48,202 +48,199 @@ class _InventoryBrowserAppBarState extends State<InventoryBrowserAppBar> {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: Provider.of<InventoryClient>(context),
child: Consumer<InventoryClient>(
builder: (BuildContext context, InventoryClient iClient, Widget? child) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: !iClient.isAnyRecordSelected
? AppBar(
key: const ValueKey("default-appbar"),
title: const Text("Inventory"),
)
: AppBar(
key: const ValueKey("selection-appbar"),
title: Text("${iClient.selectedRecordCount} Selected"),
leading: IconButton(
onPressed: () {
iClient.clearSelectedRecords();
},
icon: const Icon(Icons.close),
),
actions: [
if (iClient.onlyFilesSelected)
IconButton(
onPressed: () async {
final selectedRecords = iClient.selectedRecords;
final assetUris = selectedRecords.map((record) => record.assetUri).toList();
final thumbUris = selectedRecords.map((record) => record.thumbnailUri).toList();
final selectedUris = await showDialog<List<String>>(
context: context,
builder: (context) {
return AlertDialog(
icon: const Icon(Icons.download),
title: const Text("Download what?"),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
const SizedBox(
height: 8,
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(assetUris);
},
icon: const Icon(Icons.data_object),
label: Text(
"Asset${iClient.selectedRecordCount != 1 ? "s" : ""} (${assetUris.map((e) => extension(e)).toList().unique().join(", ")})",
),
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(thumbUris);
},
icon: const Icon(Icons.image),
label: Text(
"Thumbnail${iClient.selectedRecordCount != 1 ? "s" : ""} (${thumbUris.map((e) => extension(e)).toList().unique().join(", ")})",
),
),
],
),
);
},
);
if (selectedUris == null) return;
final directory = await FilePicker.platform.getDirectoryPath(dialogTitle: "Download to...");
if (directory == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Selection aborted."),
),
);
}
return;
}
if (directory == "/") {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Selected directory is invalid"),
),
);
}
return;
}
for (var record in selectedRecords) {
final uri = selectedUris == thumbUris ? record.thumbnailUri : record.thumbnailUri;
await FlutterDownloader.enqueue(
url: Aux.neosDbToHttp(uri),
savedDir: directory,
showNotification: true,
openFileFromNotification: false,
fileName:
"${record.id.split("-")[1]}-${record.formattedName.toString()}${extension(uri)}",
);
}
iClient.clearSelectedRecords();
},
icon: const Icon(Icons.download),
),
const SizedBox(
width: 4,
),
return Consumer<InventoryClient>(
builder: (BuildContext context, InventoryClient iClient, Widget? child) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: !iClient.isAnyRecordSelected
? AppBar(
key: const ValueKey("default-appbar"),
title: const Text("Inventory"),
)
: AppBar(
key: const ValueKey("selection-appbar"),
title: Text("${iClient.selectedRecordCount} Selected"),
leading: IconButton(
onPressed: () {
iClient.clearSelectedRecords();
},
icon: const Icon(Icons.close),
),
actions: [
if (iClient.onlyFilesSelected)
IconButton(
onPressed: () async {
var loading = false;
await showDialog(
final selectedRecords = iClient.selectedRecords;
final assetUris = selectedRecords.map((record) => record.assetUri).toList();
final thumbUris = selectedRecords.map((record) => record.thumbnailUri).toList();
final selectedUris = await showDialog<List<String>>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
icon: const Icon(Icons.delete),
title: Text(iClient.selectedRecordCount == 1
? "Really delete this Record?"
: "Really delete ${iClient.selectedRecordCount} Records?"),
content: const Text("This action cannot be undone!"),
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
TextButton(
onPressed: loading
? null
: () {
Navigator.of(context).pop(false);
},
child: const Text("Cancel"),
return AlertDialog(
icon: const Icon(Icons.download),
title: const Text("Download what?"),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
const SizedBox(
height: 8,
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(assetUris);
},
icon: const Icon(Icons.data_object),
label: Text(
"Asset${iClient.selectedRecordCount != 1 ? "s" : ""} (${assetUris.map((e) => extension(e)).toList().unique().join(", ")})",
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (loading)
const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(
width: 4,
),
TextButton(
onPressed: loading
? null
: () async {
setState(() {
loading = true;
});
try {
await iClient.deleteSelectedRecords();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Failed to delete one or more records: $e"),
),
);
}
setState(() {
loading = false;
});
}
if (context.mounted) {
Navigator.of(context).pop(true);
}
iClient.reloadCurrentDirectory();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text("Delete"),
),
],
),
TextButton.icon(
onPressed: () {
Navigator.of(context).pop(thumbUris);
},
icon: const Icon(Icons.image),
label: Text(
"Thumbnail${iClient.selectedRecordCount != 1 ? "s" : ""} (${thumbUris.map((e) => extension(e)).toList().unique().join(", ")})",
),
],
);
},
),
],
),
);
},
);
if (selectedUris == null) return;
final directory = await FilePicker.platform.getDirectoryPath(dialogTitle: "Download to...");
if (directory == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Selection aborted."),
),
);
}
return;
}
if (directory == "/") {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Selected directory is invalid"),
),
);
}
return;
}
for (var record in selectedRecords) {
final uri = selectedUris == thumbUris ? record.thumbnailUri : record.thumbnailUri;
await FlutterDownloader.enqueue(
url: Aux.neosDbToHttp(uri),
savedDir: directory,
showNotification: true,
openFileFromNotification: false,
fileName:
"${record.id.split("-")[1]}-${record.formattedName.toString()}${extension(uri)}",
);
}
iClient.clearSelectedRecords();
},
icon: const Icon(Icons.delete),
icon: const Icon(Icons.download),
),
const SizedBox(
width: 4,
),
],
),
);
},
),
const SizedBox(
width: 4,
),
IconButton(
onPressed: () async {
var loading = false;
await showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
icon: const Icon(Icons.delete),
title: Text(iClient.selectedRecordCount == 1
? "Really delete this Record?"
: "Really delete ${iClient.selectedRecordCount} Records?"),
content: const Text("This action cannot be undone!"),
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
TextButton(
onPressed: loading
? null
: () {
Navigator.of(context).pop(false);
},
child: const Text("Cancel"),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (loading)
const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(
width: 4,
),
TextButton(
onPressed: loading
? null
: () async {
setState(() {
loading = true;
});
try {
await iClient.deleteSelectedRecords();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Failed to delete one or more records: $e"),
),
);
}
setState(() {
loading = false;
});
}
if (context.mounted) {
Navigator.of(context).pop(true);
}
iClient.reloadCurrentDirectory();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text("Delete"),
),
],
),
],
);
},
);
},
);
},
icon: const Icon(Icons.delete),
),
const SizedBox(
width: 4,
),
],
),
);
},
);
}
}

View file

@ -1,6 +1,8 @@
import 'dart:math';
import 'package:contacts_plus_plus/client_holder.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/session.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -44,9 +46,20 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
super.dispose();
}
Future<void> _updateSettings() async {
final settingsClient = ClientHolder.of(context).settingsClient;
await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(
sessionViewLastMinimumUsers: _currentFilter.minActiveUsers,
sessionViewLastIncludeEnded: _currentFilter.includeEnded,
sessionViewLastIncludeEmpty: _currentFilter.includeEmptyHeadless,
sessionViewLastIncludeIncompatible: _currentFilter.includeIncompatible,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
insetPadding: const EdgeInsets.all(24),
title: const Text("Filter"),
content: SizedBox(
width: double.infinity,
@ -109,7 +122,8 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
IconButton(
onPressed: () {
setState(() {
_currentFilter = _currentFilter.copyWith(minActiveUsers: _currentFilter.minActiveUsers + 1, includeEmptyHeadless: false);
_currentFilter = _currentFilter.copyWith(
minActiveUsers: _currentFilter.minActiveUsers + 1, includeEmptyHeadless: false);
});
},
icon: const Icon(Icons.add_circle_outline),
@ -128,11 +142,13 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
SessionFilterCheckbox(
label: "Include Empty Headless",
value: _currentFilter.includeEmptyHeadless && _currentFilter.minActiveUsers == 0,
onChanged: _currentFilter.minActiveUsers > 0 ? null : (value) {
setState(() {
_currentFilter = _currentFilter.copyWith(includeEmptyHeadless: value);
});
},
onChanged: _currentFilter.minActiveUsers > 0
? null
: (value) {
setState(() {
_currentFilter = _currentFilter.copyWith(includeEmptyHeadless: value);
});
},
),
SessionFilterCheckbox(
label: "Include Incompatible",
@ -155,9 +171,10 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
onPressed: () async {
Provider.of<SessionClient>(context, listen: false).filterSettings = _currentFilter;
Navigator.of(context).pop();
await _updateSettings();
},
child: const Text("Okay"),
),

View file

@ -21,7 +21,7 @@ class _SessionListState extends State<SessionList> with AutomaticKeepAliveClient
super.didChangeDependencies();
final sClient = Provider.of<SessionClient>(context, listen: false);
if (sClient.sessionsFuture == null) {
sClient.reloadSessions();
sClient.initSessions();
}
}

View file

@ -1,4 +1,6 @@
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/session_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/widgets/sessions/session_filter_dialog.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -23,7 +25,7 @@ class _SessionListAppBarState extends State<SessionListAppBar> {
final sessionClient = Provider.of<SessionClient>(context, listen: false);
await showDialog(
context: context,
builder: (context) => Provider.value(
builder: (context) => ChangeNotifierProvider.value(
value: sessionClient,
child: SessionFilterDialog(
lastFilter: sessionClient.filterSettings,

View file

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.4.1+1
version: 1.4.2+1
environment:
sdk: '>=3.0.1'