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:collection/collection.dart';
import 'package:contacts_plus_plus/apis/session_api.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/api_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/session.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class SessionClient extends ChangeNotifier { class SessionClient extends ChangeNotifier {
final ApiClient apiClient; final ApiClient apiClient;
final SettingsClient settingsClient;
Future<List<Session>>? _sessionsFuture; Future<List<Session>>? _sessionsFuture;
SessionFilterSettings _filterSettings = SessionFilterSettings.empty(); 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; SessionFilterSettings get filterSettings => _filterSettings;
@ -22,12 +33,16 @@ class SessionClient extends ChangeNotifier {
reloadSessions(); reloadSessions();
} }
void reloadSessions() { void initSessions() {
_sessionsFuture = SessionApi.getSessions(apiClient, filterSettings: _filterSettings).then( _sessionsFuture = SessionApi.getSessions(apiClient, filterSettings: _filterSettings).then(
(value) => value.sorted( (value) => value.sorted(
(a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length), (a, b) => b.sessionUsers.length.compareTo(a.sessionUsers.length),
), ),
); );
}
void reloadSessions() {
initSessions();
notifyListeners(); 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/session_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart'; import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/sem_ver.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart';
import 'package:contacts_plus_plus/widgets/friends/friends_list_app_bar.dart';
import 'package:contacts_plus_plus/widgets/homepage.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/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:contacts_plus_plus/widgets/update_notifier.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
@ -35,8 +30,6 @@ void main() async {
debug: kDebugMode, debug: kDebugMode,
); );
Provider.debugCheckInvalidValueType = null;
await Hive.initFlutter(); await Hive.initFlutter();
final dateFormat = DateFormat.Hms(); final dateFormat = DateFormat.Hms();
@ -156,19 +149,19 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
return _authData.isAuthenticated return _authData.isAuthenticated
? MultiProvider( ? MultiProvider(
providers: [ providers: [
Provider( ChangeNotifierProvider(
create: (context) => MessagingClient( create: (context) => MessagingClient(
apiClient: clientHolder.apiClient, apiClient: clientHolder.apiClient,
notificationClient: clientHolder.notificationClient, notificationClient: clientHolder.notificationClient,
), ),
dispose: (context, value) => value.dispose(),
), ),
Provider( ChangeNotifierProvider(
create: (context) => SessionClient( create: (context) => SessionClient(
apiClient: clientHolder.apiClient, apiClient: clientHolder.apiClient,
settingsClient: clientHolder.settingsClient,
), ),
), ),
Provider( ChangeNotifierProvider(
create: (context) => InventoryClient( create: (context) => InventoryClient(
apiClient: clientHolder.apiClient, apiClient: clientHolder.apiClient,
), ),

View file

@ -40,31 +40,47 @@ class Settings {
final SettingsEntry<String> lastDismissedVersion; final SettingsEntry<String> lastDismissedVersion;
final SettingsEntry<String> machineId; final SettingsEntry<String> machineId;
final SettingsEntry<int> themeMode; final SettingsEntry<int> themeMode;
final SettingsEntry<int> sessionViewLastMinimumUsers;
final SettingsEntry<bool> sessionViewLastIncludeEnded;
final SettingsEntry<bool> sessionViewLastIncludeEmpty;
final SettingsEntry<bool> sessionViewLastIncludeIncompatible;
Settings({ Settings({
SettingsEntry<bool>? notificationsDenied, SettingsEntry<bool>? notificationsDenied,
SettingsEntry<int>? lastOnlineStatus, SettingsEntry<int>? lastOnlineStatus,
SettingsEntry<int>? themeMode, SettingsEntry<int>? themeMode,
SettingsEntry<String>? lastDismissedVersion, SettingsEntry<String>? lastDismissedVersion,
SettingsEntry<String>? machineId SettingsEntry<String>? machineId,
}) SettingsEntry<int>? sessionViewLastMinimumUsers,
: notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false), SettingsEntry<bool>? sessionViewLastIncludeEnded,
SettingsEntry<bool>? sessionViewLastIncludeEmpty,
SettingsEntry<bool>? sessionViewLastIncludeIncompatible,
}) : notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index), lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index),
themeMode = themeMode ?? SettingsEntry<int>(deflt: ThemeMode.dark.index), themeMode = themeMode ?? SettingsEntry<int>(deflt: ThemeMode.dark.index),
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString()), 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) { factory Settings.fromMap(Map map) {
return Settings( return Settings(
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]), notificationsDenied: getEntryOrNull<bool>(map["notificationsDenied"]),
lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]), lastOnlineStatus: getEntryOrNull<int>(map["lastOnlineStatus"]),
themeMode: retrieveEntryOrNull<int>(map["themeMode"]), themeMode: getEntryOrNull<int>(map["themeMode"]),
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"]), lastDismissedVersion: getEntryOrNull<String>(map["lastDismissedVersion"]),
machineId: retrieveEntryOrNull<String>(map["machineId"]), 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; if (map == null) return null;
try { try {
return SettingsEntry<T>.fromMap(map); return SettingsEntry<T>.fromMap(map);
@ -80,6 +96,10 @@ class Settings {
"themeMode": themeMode.toMap(), "themeMode": themeMode.toMap(),
"lastDismissedVersion": lastDismissedVersion.toMap(), "lastDismissedVersion": lastDismissedVersion.toMap(),
"machineId": machineId.toMap(), "machineId": machineId.toMap(),
"sessionViewLastMinimumUsers": sessionViewLastMinimumUsers.toMap(),
"sessionViewLastIncludeEnded": sessionViewLastIncludeEnded.toMap(),
"sessionViewLastIncludeEmpty": sessionViewLastIncludeEmpty.toMap(),
"sessionViewLastIncludeIncompatible": sessionViewLastIncludeIncompatible.toMap(),
}; };
} }
@ -91,6 +111,10 @@ class Settings {
int? themeMode, int? themeMode,
String? lastDismissedVersion, String? lastDismissedVersion,
String? machineId, String? machineId,
int? sessionViewLastMinimumUsers,
bool? sessionViewLastIncludeEnded,
bool? sessionViewLastIncludeEmpty,
bool? sessionViewLastIncludeIncompatible,
}) { }) {
return Settings( return Settings(
notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied),
@ -98,6 +122,11 @@ class Settings {
themeMode: this.themeMode.passThrough(themeMode), themeMode: this.themeMode.passThrough(themeMode),
lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion), lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion),
machineId: this.machineId.passThrough(machineId), 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class FriendsList extends StatefulWidget { class FriendsList extends StatefulWidget {
const FriendsList({super.key}); const FriendsList({super.key});
@ -19,50 +18,52 @@ class _FriendsListState extends State<FriendsList> with AutomaticKeepAliveClient
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
return ChangeNotifierProvider.value( return Consumer<MessagingClient>(
value: Provider.of<MessagingClient>(context, listen: false), builder: (context, mClient, _) {
child: Stack( return Stack(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
children: [ children: [
Consumer<MessagingClient>(builder: (context, mClient, _) { Builder(
if (mClient.initStatus == null) { builder: (context) {
return const LinearProgressIndicator(); if (mClient.initStatus == null) {
} else if (mClient.initStatus!.isNotEmpty) { return const LinearProgressIndicator();
return Column( } else if (mClient.initStatus!.isNotEmpty) {
children: [ return Column(
Expanded( children: [
child: DefaultErrorWidget( Expanded(
message: mClient.initStatus, child: DefaultErrorWidget(
onRetry: () async { message: mClient.initStatus,
mClient.resetInitStatus(); onRetry: () async {
mClient.refreshFriendsListWithErrorHandler(); mClient.resetInitStatus();
}, mClient.refreshFriendsListWithErrorHandler();
},
),
), ),
), ],
], );
); } else {
} else { var friends = List.from(mClient.cachedFriends); // Explicit copy.
var friends = List.from(mClient.cachedFriends); // Explicit copy. if (_searchFilter.isNotEmpty) {
if (_searchFilter.isNotEmpty) { friends = friends
friends = friends .where((element) => element.username.toLowerCase().contains(_searchFilter.toLowerCase()))
.where((element) => element.username.toLowerCase().contains(_searchFilter.toLowerCase())) .toList();
.toList(); friends.sort((a, b) => a.username.length.compareTo(b.username.length));
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( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: ExpandingInputFab( 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
return ChangeNotifierProvider.value( return AppBar(
value: Provider.of<MessagingClient>(context, listen: false), title: const Text("Contacts++"),
child: AppBar( actions: [
title: const Text("Contacts++"), FutureBuilder(
actions: [ future: _userStatusFuture,
FutureBuilder( builder: (context, snapshot) {
future: _userStatusFuture, if (snapshot.hasData) {
builder: (context, snapshot) { final userStatus = snapshot.data as UserStatus;
if (snapshot.hasData) { return PopupMenuButton<OnlineStatus>(
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,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(item.name), Padding(
Icon(item.icon), 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,
.toList(), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
return ChangeNotifierProvider.value( return Consumer<InventoryClient>(builder: (BuildContext context, InventoryClient iClient, Widget? child) {
value: Provider.of<InventoryClient>(context), return FutureBuilder<NeosDirectory>(
child: Consumer<InventoryClient>(builder: (BuildContext context, InventoryClient iClient, Widget? child) { future: iClient.directoryFuture,
return FutureBuilder<NeosDirectory>( builder: (context, snapshot) {
future: iClient.directoryFuture, final currentDir = snapshot.data;
builder: (context, snapshot) { return WillPopScope(
final currentDir = snapshot.data; onWillPop: () async {
return WillPopScope( // Allow pop when at root or not loaded
onWillPop: () async { if (currentDir?.isRoot ?? true) {
// Allow pop when at root or not loaded return true;
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( child: Builder(
onRefresh: () async { builder: (context) {
if (_refreshLimiter?.isActive ?? false) return; if (snapshot.hasError) {
try { FlutterError.reportError(
await iClient.reloadCurrentDirectory(); FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
_refreshLimiter = Timer(_refreshLimit, () {}); return DefaultErrorWidget(
} catch (e) { message: snapshot.error.toString(),
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e"))); onRetry: () {
iClient.loadInventoryRoot();
iClient.forceNotify();
},
);
} }
}, final directory = snapshot.data;
child: Builder( final records = directory?.records ?? [];
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 ?? [];
records.sort((a, b) => a.name.compareTo(b.name)); records.sort((a, b) => a.name.compareTo(b.name));
final paths = records final paths = records
.where((element) => .where((element) =>
element.recordType == RecordType.link || element.recordType == RecordType.directory) element.recordType == RecordType.link || element.recordType == RecordType.directory)
.toList(); .toList();
final objects = records final objects = records
.where((element) => .where((element) =>
element.recordType != RecordType.link && element.recordType != RecordType.directory) element.recordType != RecordType.link && element.recordType != RecordType.directory)
.toList(); .toList();
final pathSegments = directory?.absolutePathSegments ?? []; final pathSegments = directory?.absolutePathSegments ?? [];
return Stack( return Stack(
children: [ children: [
ListView( ListView(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Wrap( child: Wrap(
children: pathSegments children: pathSegments
.mapIndexed( .mapIndexed(
(idx, segment) => Row( (idx, segment) => Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (idx != 0) const Icon(Icons.chevron_right), if (idx != 0) const Icon(Icons.chevron_right),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton( child: TextButton(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: idx == pathSegments.length - 1 foregroundColor: idx == pathSegments.length - 1
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface, : Theme.of(context).colorScheme.onSurface,
), ),
onPressed: () { onPressed: () {
iClient.navigateUp(times: pathSegments.length - 1 - idx); iClient.navigateUp(times: pathSegments.length - 1 - idx);
}, },
child: Text(segment), 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),
), ),
), ),
], );
), },
) onLongPress: () async {
.toList(), iClient.toggleRecordSelected(record);
), },
), );
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,
), ),
],
),
Align(
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: snapshot.connectionState == ConnectionState.waiting
? const LinearProgressIndicator()
: null,
), ),
Align( ),
alignment: Alignment.topCenter, Align(
child: AnimatedSwitcher( alignment: Alignment.topCenter,
duration: const Duration(milliseconds: 250), child: AnimatedSwitcher(
child: snapshot.connectionState == ConnectionState.waiting duration: const Duration(milliseconds: 250),
? Container( child: snapshot.connectionState == ConnectionState.waiting
width: double.infinity, ? Container(
height: double.infinity, width: double.infinity,
color: Colors.black38, height: double.infinity,
) color: Colors.black38,
: null, )
), : null,
) ),
], )
); ],
}, );
), },
), ),
); ),
}); );
}), });
); });
} }
@override @override

View file

@ -48,202 +48,199 @@ class _InventoryBrowserAppBarState extends State<InventoryBrowserAppBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider.value( return Consumer<InventoryClient>(
value: Provider.of<InventoryClient>(context), builder: (BuildContext context, InventoryClient iClient, Widget? child) {
child: Consumer<InventoryClient>( return AnimatedSwitcher(
builder: (BuildContext context, InventoryClient iClient, Widget? child) { duration: const Duration(milliseconds: 350),
return AnimatedSwitcher( transitionBuilder: (child, animation) => FadeTransition(
duration: const Duration(milliseconds: 350), opacity: animation,
transitionBuilder: (child, animation) => FadeTransition( child: child,
opacity: animation, ),
child: child, child: !iClient.isAnyRecordSelected
), ? AppBar(
child: !iClient.isAnyRecordSelected key: const ValueKey("default-appbar"),
? AppBar( title: const Text("Inventory"),
key: const ValueKey("default-appbar"), )
title: const Text("Inventory"), : AppBar(
) key: const ValueKey("selection-appbar"),
: AppBar( title: Text("${iClient.selectedRecordCount} Selected"),
key: const ValueKey("selection-appbar"), leading: IconButton(
title: Text("${iClient.selectedRecordCount} Selected"), onPressed: () {
leading: IconButton( iClient.clearSelectedRecords();
onPressed: () { },
iClient.clearSelectedRecords(); icon: const Icon(Icons.close),
}, ),
icon: const Icon(Icons.close), actions: [
), if (iClient.onlyFilesSelected)
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,
),
IconButton( IconButton(
onPressed: () async { onPressed: () async {
var loading = false; final selectedRecords = iClient.selectedRecords;
await showDialog(
final assetUris = selectedRecords.map((record) => record.assetUri).toList();
final thumbUris = selectedRecords.map((record) => record.thumbnailUri).toList();
final selectedUris = await showDialog<List<String>>(
context: context, context: context,
builder: (context) { builder: (context) {
return StatefulBuilder( return AlertDialog(
builder: (context, setState) { icon: const Icon(Icons.download),
return AlertDialog( title: const Text("Download what?"),
icon: const Icon(Icons.delete), content: Column(
title: Text(iClient.selectedRecordCount == 1 crossAxisAlignment: CrossAxisAlignment.start,
? "Really delete this Record?" mainAxisSize: MainAxisSize.min,
: "Really delete ${iClient.selectedRecordCount} Records?"), children: [
content: const Text("This action cannot be undone!"), const Divider(),
actionsAlignment: MainAxisAlignment.spaceBetween, const SizedBox(
actions: [ height: 8,
TextButton( ),
onPressed: loading TextButton.icon(
? null onPressed: () {
: () { Navigator.of(context).pop(assetUris);
Navigator.of(context).pop(false); },
}, icon: const Icon(Icons.data_object),
child: const Text("Cancel"), label: Text(
"Asset${iClient.selectedRecordCount != 1 ? "s" : ""} (${assetUris.map((e) => extension(e)).toList().unique().join(", ")})",
), ),
Row( ),
mainAxisSize: MainAxisSize.min, TextButton.icon(
children: [ onPressed: () {
if (loading) Navigator.of(context).pop(thumbUris);
const SizedBox.square( },
dimension: 16, icon: const Icon(Icons.image),
child: CircularProgressIndicator(strokeWidth: 2), label: Text(
), "Thumbnail${iClient.selectedRecordCount != 1 ? "s" : ""} (${thumbUris.map((e) => extension(e)).toList().unique().join(", ")})",
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"),
),
],
), ),
], ),
); ],
}, ),
); );
}, },
); );
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( const SizedBox(
width: 4, 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 '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/session_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/session.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -44,9 +46,20 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
insetPadding: const EdgeInsets.all(24),
title: const Text("Filter"), title: const Text("Filter"),
content: SizedBox( content: SizedBox(
width: double.infinity, width: double.infinity,
@ -109,7 +122,8 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() { 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), icon: const Icon(Icons.add_circle_outline),
@ -128,11 +142,13 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
SessionFilterCheckbox( SessionFilterCheckbox(
label: "Include Empty Headless", label: "Include Empty Headless",
value: _currentFilter.includeEmptyHeadless && _currentFilter.minActiveUsers == 0, value: _currentFilter.includeEmptyHeadless && _currentFilter.minActiveUsers == 0,
onChanged: _currentFilter.minActiveUsers > 0 ? null : (value) { onChanged: _currentFilter.minActiveUsers > 0
setState(() { ? null
_currentFilter = _currentFilter.copyWith(includeEmptyHeadless: value); : (value) {
}); setState(() {
}, _currentFilter = _currentFilter.copyWith(includeEmptyHeadless: value);
});
},
), ),
SessionFilterCheckbox( SessionFilterCheckbox(
label: "Include Incompatible", label: "Include Incompatible",
@ -155,9 +171,10 @@ class _SessionFilterDialogState extends State<SessionFilterDialog> {
child: const Text("Cancel"), child: const Text("Cancel"),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
Provider.of<SessionClient>(context, listen: false).filterSettings = _currentFilter; Provider.of<SessionClient>(context, listen: false).filterSettings = _currentFilter;
Navigator.of(context).pop(); Navigator.of(context).pop();
await _updateSettings();
}, },
child: const Text("Okay"), child: const Text("Okay"),
), ),

View file

@ -21,7 +21,7 @@ class _SessionListState extends State<SessionList> with AutomaticKeepAliveClient
super.didChangeDependencies(); super.didChangeDependencies();
final sClient = Provider.of<SessionClient>(context, listen: false); final sClient = Provider.of<SessionClient>(context, listen: false);
if (sClient.sessionsFuture == null) { 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/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:contacts_plus_plus/widgets/sessions/session_filter_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -23,7 +25,7 @@ class _SessionListAppBarState extends State<SessionListAppBar> {
final sessionClient = Provider.of<SessionClient>(context, listen: false); final sessionClient = Provider.of<SessionClient>(context, listen: false);
await showDialog( await showDialog(
context: context, context: context,
builder: (context) => Provider.value( builder: (context) => ChangeNotifierProvider.value(
value: sessionClient, value: sessionClient,
child: SessionFilterDialog( child: SessionFilterDialog(
lastFilter: sessionClient.filterSettings, 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 # 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 # 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. # 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: environment:
sdk: '>=3.0.1' sdk: '>=3.0.1'