feat: add library reordering functionality (#6)

- Add reorder mode toggle button in Libraries screen app bar
- Implement drag & drop reordering for library chips
- Persist library order to local storage using SharedPreferences
- Restore saved library order on app launch
- Update selected library index when libraries are reordered
- Add visual feedback with drag handle icon in reorder mode

Fixes #6
This commit is contained in:
Sam Matthews
2025-11-04 04:29:26 +00:00
parent ed668ba0e4
commit f06deba0cc
2 changed files with 193 additions and 42 deletions
+173 -42
View File
@@ -35,6 +35,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
int _selectedLibraryIndex = 0;
Map<String, String> _selectedFilters = {};
bool _isInitialLoad = true;
bool _isReorderMode = false;
@override
void initState() {
@@ -59,8 +60,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
final libraries = await client.getLibraries();
// Load saved library order and apply it
final storage = await StorageService.getInstance();
final savedOrder = storage.getLibraryOrder();
final orderedLibraries = _applyLibraryOrder(libraries, savedOrder);
setState(() {
_libraries = libraries;
_libraries = orderedLibraries;
_isLoadingLibraries = false;
});
@@ -91,6 +98,67 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
}
List<PlexLibrary> _applyLibraryOrder(
List<PlexLibrary> libraries,
List<String>? savedOrder,
) {
if (savedOrder == null || savedOrder.isEmpty) {
return libraries;
}
// Create a map for quick lookup
final libraryMap = {for (var lib in libraries) lib.key: lib};
// Build ordered list based on saved order
final orderedLibraries = <PlexLibrary>[];
final addedKeys = <String>{};
// Add libraries in saved order
for (final key in savedOrder) {
if (libraryMap.containsKey(key)) {
orderedLibraries.add(libraryMap[key]!);
addedKeys.add(key);
}
}
// Add any new libraries that weren't in the saved order
for (final library in libraries) {
if (!addedKeys.contains(library.key)) {
orderedLibraries.add(library);
}
}
return orderedLibraries;
}
Future<void> _saveLibraryOrder() async {
final storage = await StorageService.getInstance();
final libraryKeys = _libraries.map((lib) => lib.key).toList();
await storage.saveLibraryOrder(libraryKeys);
}
void _reorderLibraries(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final library = _libraries.removeAt(oldIndex);
_libraries.insert(newIndex, library);
// Update selected index to follow the moved library if needed
if (oldIndex == _selectedLibraryIndex) {
_selectedLibraryIndex = newIndex;
} else if (oldIndex < _selectedLibraryIndex &&
newIndex >= _selectedLibraryIndex) {
_selectedLibraryIndex -= 1;
} else if (oldIndex > _selectedLibraryIndex &&
newIndex <= _selectedLibraryIndex) {
_selectedLibraryIndex += 1;
}
});
_saveLibraryOrder();
}
Future<void> _loadLibraryContent(int index) async {
if (index < 0 || index >= _libraries.length) return;
@@ -261,6 +329,18 @@ class _LibrariesScreenState extends State<LibrariesScreen>
shadowColor: Colors.transparent,
scrolledUnderElevation: 0,
actions: [
if (_libraries.isNotEmpty)
IconButton(
icon: Icon(
_isReorderMode ? Icons.check : Icons.edit,
semanticLabel: _isReorderMode ? 'Done' : 'Reorder',
),
onPressed: () {
setState(() {
_isReorderMode = !_isReorderMode;
});
},
),
if (_filters.isNotEmpty)
IconButton(
icon: Badge(
@@ -326,53 +406,104 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Library selector chips
SliverToBoxAdapter(
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(_libraries.length, (index) {
final library = _libraries[index];
final isSelected = index == _selectedLibraryIndex;
final t = tokens(context);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getLibraryIcon(library.type),
size: 16,
color: isSelected ? t.bg : t.text,
child: _isReorderMode
? ReorderableListView.builder(
scrollDirection: Axis.horizontal,
onReorder: _reorderLibraries,
itemCount: _libraries.length,
proxyDecorator: (child, index, animation) {
return Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: child,
);
},
itemBuilder: (context, index) {
final library = _libraries[index];
final isSelected = index == _selectedLibraryIndex;
final t = tokens(context);
return Container(
key: ValueKey(library.key),
margin: const EdgeInsets.only(right: 8),
child: Chip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.drag_handle,
size: 16,
color: isSelected ? t.bg : t.text,
),
const SizedBox(width: 6),
Icon(
_getLibraryIcon(library.type),
size: 16,
color: isSelected ? t.bg : t.text,
),
const SizedBox(width: 6),
Text(library.title),
],
),
const SizedBox(width: 6),
Text(library.title),
],
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
_loadLibraryContent(index);
}
},
backgroundColor: t.surface,
selectedColor: t.text,
side: BorderSide(color: t.outline),
labelStyle: TextStyle(
color: isSelected ? t.bg : t.text,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
showCheckmark: false,
backgroundColor: isSelected ? t.text : t.surface,
side: BorderSide(color: t.outline),
labelStyle: TextStyle(
color: isSelected ? t.bg : t.text,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
),
);
},
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(_libraries.length, (index) {
final library = _libraries[index];
final isSelected = index == _selectedLibraryIndex;
final t = tokens(context);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getLibraryIcon(library.type),
size: 16,
color: isSelected ? t.bg : t.text,
),
const SizedBox(width: 6),
Text(library.title),
],
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
_loadLibraryContent(index);
}
},
backgroundColor: t.surface,
selectedColor: t.text,
side: BorderSide(color: t.outline),
labelStyle: TextStyle(
color: isSelected ? t.bg : t.text,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
showCheckmark: false,
),
);
}),
),
);
}),
),
),
),
),
),
+20
View File
@@ -9,6 +9,7 @@ class StorageService {
static const String _keyClientId = 'client_identifier';
static const String _keySelectedLibraryIndex = 'selected_library_index';
static const String _keyLibraryFilters = 'library_filters';
static const String _keyLibraryOrder = 'library_order';
static const String _keyUserProfile = 'user_profile';
static const String _keyCurrentUserUUID = 'current_user_uuid';
static const String _keyHomeUsersCache = 'home_users_cache';
@@ -167,9 +168,28 @@ class StorageService {
await Future.wait([
_prefs.remove(_keySelectedLibraryIndex),
_prefs.remove(_keyLibraryFilters),
_prefs.remove(_keyLibraryOrder),
]);
}
// Library Order (stored as JSON list of library keys)
Future<void> saveLibraryOrder(List<String> libraryKeys) async {
final jsonString = json.encode(libraryKeys);
await _prefs.setString(_keyLibraryOrder, jsonString);
}
List<String>? getLibraryOrder() {
final jsonString = _prefs.getString(_keyLibraryOrder);
if (jsonString == null) return null;
try {
final decoded = json.decode(jsonString) as List<dynamic>;
return decoded.map((e) => e.toString()).toList();
} catch (e) {
return null;
}
}
// User Profile (stored as JSON string)
Future<void> saveUserProfile(Map<String, dynamic> profileJson) async {
final jsonString = json.encode(profileJson);