Enhance category management with animation

This commit is contained in:
Moustapha Kodjo Amadou 2026-01-05 12:38:30 +01:00
parent db24951673
commit 701a696820

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar_community/isar.dart';
@ -91,27 +93,75 @@ class CategoriesTab extends ConsumerStatefulWidget {
ConsumerState<CategoriesTab> createState() => _CategoriesTabState();
}
class _CategoriesTabState extends ConsumerState<CategoriesTab> {
class _CategoriesTabState extends ConsumerState<CategoriesTab>
with SingleTickerProviderStateMixin {
List<Category> _entries = [];
late AnimationController _swapAnimationController;
int? _animatingFromIndex;
int? _animatingToIndex;
@override
void initState() {
super.initState();
_swapAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
@override
void dispose() {
_swapAnimationController.dispose();
super.dispose();
}
bool get _isDesktop {
if (kIsWeb) return false;
return Platform.isMacOS || Platform.isLinux || Platform.isWindows;
}
/// Moves a category from `index` to `newIndex` in the list,
/// swaps their positions in memory, and persists the change in Isar.
Future<void> _moveCategory(int index, int newIndex) async {
// Prevent invalid moves (out of bounds)
if (newIndex < 0 || newIndex >= _entries.length) return;
// Grab the two category objects involved in the swap
final a = _entries[index];
final b = _entries[newIndex];
// Swap their positions inside the inmemory list
_entries[newIndex] = a;
_entries[index] = b;
// Swap their persisted `pos` values so ordering is saved correctly
final temp = a.pos;
a.pos = b.pos;
b.pos = temp;
// Persist both updated objects in a single Isar transaction
await isar.writeTxn(() async => isar.categorys.putAll([a, b]));
setState(() {}); // Trigger a UI rebuild to reflect the updated order
if (_isDesktop && mounted) {
setState(() {
_animatingFromIndex = index;
_animatingToIndex = newIndex;
});
await _swapAnimationController.forward(from: 0.0);
// Grab the two category objects involved in the swap
final a = _entries[index];
final b = _entries[newIndex];
// Swap their positions inside the inmemory list
_entries[newIndex] = a;
_entries[index] = b;
// Swap their persisted `pos` values so ordering is saved correctly
final temp = a.pos;
a.pos = b.pos;
b.pos = temp;
// Persist both updated objects in a single Isar transaction
await isar.writeTxn(() async => isar.categorys.putAll([a, b]));
setState(() {
_animatingFromIndex = null;
_animatingToIndex = null;
});
} else {
final a = _entries[index];
final b = _entries[newIndex];
_entries[newIndex] = a;
_entries[index] = b;
final temp = a.pos;
a.pos = b.pos;
b.pos = temp;
await isar.writeTxn(() async => isar.categorys.putAll([a, b]));
setState(() {});
}
}
@override
@ -143,176 +193,37 @@ class _CategoriesTabState extends ConsumerState<CategoriesTab> {
padding: const EdgeInsets.only(bottom: 100),
itemBuilder: (context, index) {
final category = _entries[index];
return AnimatedSwitcher(
duration: const Duration(milliseconds: 900),
child: Padding(
key: Key('category_${category.id}'),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
child: Column(
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
elevation: 0,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0),
topRight: Radius.circular(10),
topLeft: Radius.circular(10),
),
),
),
onPressed: () {
_renameCategory(category);
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Icon(Icons.label_outline_rounded),
const SizedBox(width: 10),
Expanded(child: Text(category.name!)),
],
),
Widget itemWidget = _buildCategoryCard(context, category, index);
if (_isDesktop &&
_animatingFromIndex != null &&
_animatingToIndex != null) {
if (index == _animatingFromIndex ||
index == _animatingToIndex) {
final isMovingDown =
_animatingFromIndex! < _animatingToIndex!;
final offset = index == _animatingFromIndex
? (isMovingDown ? 1.0 : -1.0)
: (isMovingDown ? -1.0 : 1.0);
itemWidget = AnimatedBuilder(
animation: _swapAnimationController,
builder: (context, child) {
return Transform.translate(
offset: Offset(
0,
offset * (1 - _swapAnimationController.value) * 80,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Row(
children: [
const SizedBox(width: 10),
IconButton(
icon: const Icon(
Icons.arrow_drop_up_outlined,
),
onPressed: index > 0
? () {
_moveCategory(index, index - 1);
}
: null,
),
IconButton(
icon: const Icon(
Icons.arrow_drop_down_outlined,
),
onPressed: index < _entries.length - 1
? () {
_moveCategory(index, index + 1);
}
: null,
),
],
),
],
),
Row(
children: [
IconButton(
onPressed: () {
_renameCategory(category);
},
icon: const Icon(
Icons.mode_edit_outline_outlined,
),
),
SizedBox(width: 10),
IconButton(
onPressed: () async {
await isar.writeTxn(() async {
category.hide = !(category.hide ?? false);
category.updatedAt =
DateTime.now().millisecondsSinceEpoch;
isar.categorys.put(category);
});
},
icon: Icon(
!(category.hide ?? false)
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
SizedBox(width: 10),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(l10n.delete_category),
content: Text(
l10n.delete_category_msg(
category.name!,
),
),
actions: [
Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(l10n.cancel),
),
const SizedBox(width: 15),
TextButton(
onPressed: () async {
await _removeCategory(
category,
context,
);
},
child: Text(l10n.ok),
),
],
),
],
);
},
);
},
);
},
icon: const Icon(Icons.delete_outlined),
),
],
),
],
),
],
),
),
),
transitionBuilder: (Widget child, Animation<double> animation) {
return SlideTransition(
position:
Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.fastLinearToSlowEaseIn,
),
),
child: SizeTransition(
sizeFactor: CurvedAnimation(
parent: animation,
curve: Curves.fastLinearToSlowEaseIn,
),
axisAlignment: 0.5,
child: child,
),
child: child,
);
},
child: itemWidget,
);
},
);
}
}
return itemWidget;
},
);
},
@ -428,6 +339,150 @@ class _CategoriesTabState extends ConsumerState<CategoriesTab> {
);
}
Widget _buildCategoryCard(
BuildContext context,
Category category,
int index,
) {
final l10n = l10nLocalizations(context)!;
return Padding(
key: Key('category_${category.id}'),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
child: Column(
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
elevation: 0,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(0),
topRight: Radius.circular(10),
topLeft: Radius.circular(10),
),
),
),
onPressed: () {
_renameCategory(category);
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Icon(Icons.label_outline_rounded),
const SizedBox(width: 10),
Expanded(child: Text(category.name!)),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Row(
children: [
const SizedBox(width: 10),
IconButton(
icon: const Icon(Icons.arrow_drop_up_outlined),
onPressed: index > 0
? () {
_moveCategory(index, index - 1);
}
: null,
),
IconButton(
icon: const Icon(Icons.arrow_drop_down_outlined),
onPressed: index < _entries.length - 1
? () {
_moveCategory(index, index + 1);
}
: null,
),
],
),
],
),
Row(
children: [
IconButton(
onPressed: () {
_renameCategory(category);
},
icon: const Icon(Icons.mode_edit_outline_outlined),
),
SizedBox(width: 10),
IconButton(
onPressed: () async {
await isar.writeTxn(() async {
category.hide = !(category.hide ?? false);
category.updatedAt =
DateTime.now().millisecondsSinceEpoch;
isar.categorys.put(category);
});
},
icon: Icon(
!(category.hide ?? false)
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
SizedBox(width: 10),
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(l10n.delete_category),
content: Text(
l10n.delete_category_msg(category.name!),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(l10n.cancel),
),
const SizedBox(width: 15),
TextButton(
onPressed: () async {
await _removeCategory(
category,
context,
);
},
child: Text(l10n.ok),
),
],
),
],
);
},
);
},
);
},
icon: const Icon(Icons.delete_outlined),
),
],
),
],
),
],
),
),
);
}
Future<void> _removeCategory(Category category, BuildContext context) async {
await isar.writeTxn(() async {
// All Items with this category