I have an app where I have a list of timers. The Timer()
widget is very simple. It's an animated builder that has a Column()
with a Container()
containing the pause/play button, name, time left, delete, and repeat. All of this information is stored in a TimerData()
class.
The problem is that when I delete a timer above a running timer, it gets reset: https://cln.sh/VoHxCL.
I display the timers using an AnimatedList()
. Here is my code:
Timer()
:
class Timer extends StatefulWidget {
Duration time;
String name;
void Function() onDelete;
Timer(
{Key? key,
required this.time,
required this.name,
required this.onDelete})
: super(key: key);
@override
TimerState createState() => TimerState();
}
class TimerState extends State<Timer> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.time);
}
void pause() => _controller.stop();
void play() async {
if (_controller.value == 1) {
await _controller.animateTo(0,
duration: const Duration(milliseconds: 100), curve: Curves.linear);
}
_controller.forward();
}
void toggle() => _controller.status == AnimationStatus.forward
? _controller.stop()
: _controller.forward();
void reset() => _controller.animateTo(0,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
void _onFinish() {
// TODO play a sound and give a notification
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(10),
),
clipBehavior: Clip.hardEdge,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 5, right: 5, top: 5),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
// Play/Pause
CupertinoButton(
child: _controller.isAnimating
? const Icon(CupertinoIcons.pause_solid)
: const Icon(CupertinoIcons.play_arrow_solid),
onPressed: () => setState(() {
if (_controller.isAnimating) {
pause();
} else {
play();
}
}),
),
const SizedBox(width: 10),
// Time
Text(
'${widget.name} – ${_formatDuration(_controller) ?? 'Error formatting date'}'),
// const Text('TIME'),
],
),
Row(
children: [
// Reset
CupertinoButton(
child: const Icon(CupertinoIcons.restart),
onPressed: () => setState(() {
pause();
reset();
}),
),
// Delete
CupertinoButton(
child: const Icon(CupertinoIcons.delete_solid),
onPressed: widget.onDelete,
),
],
),
],
),
),
// Progress Bar
Align(
alignment: Alignment.centerLeft,
child: Container(
color: Theme.of(context).colorScheme.primary,
height: 5,
width: (MediaQuery.of(context).size.width - (15 * 2)) *
_controller.value,
),
),
],
),
);
});
}
}
Home()
widget containing the AnimatedList()
:
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
List<TimerData> timers = [];
@override
Widget build(BuildContext context) {
AppData data = Provider.of<AppData>(context);
return Scaffold(
extendBodyBehindAppBar: timers.isEmpty,
backgroundColor: Theme.of(context).brightness == Brightness.dark
? null
: Color.fromRGBO(
Theme.of(context).scaffoldBackgroundColor.red - 15,
Theme.of(context).scaffoldBackgroundColor.green - 15,
Theme.of(context).scaffoldBackgroundColor.blue - 15,
1),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
CupertinoButton(
child: const Icon(CupertinoIcons.add),
onPressed: () => showDialog(
context: context,
builder: (context) {
String? name;
Duration days = const Duration(days: 0);
Duration hours = const Duration(hours: 0);
Duration minutes = const Duration(minutes: 0);
Duration seconds = const Duration(seconds: 0);
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(10),
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Name:'),
const SizedBox(width: 10),
SizedBox(
height: 35,
width: 140,
child: CupertinoTextField(
onChanged: (val) => name = val,
placeholder: 'Timer',
),
),
],
),
const SizedBox(height: 20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Days:'),
const SizedBox(width: 10),
SizedBox(
height: 30,
width: 50,
child: CupertinoTextField(
placeholder: '0',
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
], // Only numbers can be entered
onChanged: (val) =>
days = Duration(days: int.parse(val).abs()),
),
),
const SizedBox(width: 5),
const Text('Hours:'),
const SizedBox(width: 10),
SizedBox(
height: 30,
width: 50,
child: CupertinoTextField(
placeholder: '0',
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
], // Only numbers can be entered
onChanged: (val) => hours =
Duration(hours: int.parse(val).abs()),
),
),
const SizedBox(width: 5),
const Text('Minutes:'),
const SizedBox(width: 10),
SizedBox(
height: 30,
width: 50,
child: CupertinoTextField(
placeholder: '0',
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
], // Only numbers can be entered
onChanged: (val) => minutes =
Duration(minutes: int.parse(val).abs()),
),
),
const SizedBox(width: 5),
const Text('Seconds:'),
const SizedBox(width: 10),
SizedBox(
height: 30,
width: 50,
child: CupertinoTextField(
placeholder: '0',
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly
], // Only numbers can be entered
onChanged: (val) => seconds =
Duration(seconds: int.parse(val).abs()),
),
),
],
),
const SizedBox(height: 20),
CupertinoButton.filled(
child: const Text('Add Timer'),
onPressed: () {
Duration time = days + hours + minutes + seconds;
if (time.inSeconds == 0) {
time = const Duration(minutes: 1);
}
setState(() {
timers.add(TimerData(
id: const Uuid().v1(),
name: name ?? 'Timer',
time: time));
if (_listKey.currentState != null) {
_listKey.currentState!.insertItem(
timers.length - 1,
duration: const Duration(milliseconds: 300),
);
}
});
Navigator.pop(context);
},
),
],
),
);
}),
),
CupertinoButton(
child: Icon(data.themeMode == ThemeMode.light
? CupertinoIcons.lightbulb_fill
: (data.themeMode == ThemeMode.dark
? CupertinoIcons.moon_stars_fill
: CupertinoIcons.gear_solid)),
onPressed: () {
// TODO change theme
// ! temp
if (data.themeMode == ThemeMode.system) {
data.setTheme(ThemeMode.light);
} else if (data.themeMode == ThemeMode.light) {
data.setTheme(ThemeMode.dark);
} else {
data.setTheme(ThemeMode.system);
}
},
),
],
),
body: timers.isNotEmpty
? AnimatedList(
key: _listKey,
initialItemCount: timers.length,
itemBuilder: (context, index, animation) {
return SizeTransition(
sizeFactor: CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
),
child: Timer(
key: ValueKey(timers[index].id),
name: timers[index].name,
time: timers[index].time,
onDelete: () {
ValueKey key = ValueKey(timers[index].id);
String name = timers[index].name;
Duration time = timers[index].time;
setState(() {
timers.removeAt(index);
_listKey.currentState!.removeItem(
index,
(context, animation) {
return SizeTransition(
sizeFactor: CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
),
child: Timer(
key: key,
name: name,
time: time,
onDelete: () {},
),
);
},
duration: const Duration(milliseconds: 300),
);
});
},
),
);
},
)
: const Center(child: Text('No Timers')),
);
}
}
I was able to fix this by usins GlobalKey
s.
I made the TimerState
public by removing the _
and made a list of GlobalKey<TimerState>
s and then added it to the Timer()
widget when building it in the aniamted list.