diff --git a/lib/modules/more/categories/categories_screen.dart b/lib/modules/more/categories/categories_screen.dart index 90806740..b725d5f5 100644 --- a/lib/modules/more/categories/categories_screen.dart +++ b/lib/modules/more/categories/categories_screen.dart @@ -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 createState() => _CategoriesTabState(); } -class _CategoriesTabState extends ConsumerState { +class _CategoriesTabState extends ConsumerState + with SingleTickerProviderStateMixin { List _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 _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 in‑memory 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 in‑memory 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 { 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 animation) { - return SlideTransition( - position: - Tween( - 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 { ); } + 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 _removeCategory(Category category, BuildContext context) async { await isar.writeTxn(() async { // All Items with this category