Last active
March 15, 2021 13:52
-
-
Save Desdaemon/ed9a867b806376ad62b06c05adee19cd to your computer and use it in GitHub Desktop.
Yet Another Flutter Todo (Null-safe + Animations + Dark Theme/List State via Riverpod + State Persistence via Hive)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* lib/state/dark.dart */ | |
import 'package:flutter_riverpod/flutter_riverpod.dart'; | |
import 'package:hive/hive.dart'; | |
final darkTheme = StateNotifierProvider((_) => Dark()); | |
class Dark extends StateNotifier<bool> { | |
final String boxname; | |
bool firstrun = true; | |
Dark({this.boxname = 'todo'}) : super(false); | |
@override | |
set state(bool dark) { | |
super.state = dark; | |
box.put('dark', dark); | |
} | |
@override | |
bool get state { | |
if (firstrun) { | |
super.state = box.get('dark', defaultValue: false) as bool; | |
firstrun = false; | |
} | |
return super.state; | |
} | |
Box get box => Hive.box(boxname); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* lib/main.dart */ | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_riverpod/flutter_riverpod.dart'; | |
import 'package:hive/hive.dart'; | |
import 'package:hive_flutter/hive_flutter.dart'; | |
import 'screens/todos.dart'; | |
import 'state/dark.dart'; | |
const boxname = 'todo'; | |
Future<void> main() async { | |
await Hive.initFlutter(); | |
await Hive.openBox(boxname); | |
runApp(ProviderScope(child: MyApp())); | |
} | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return Consumer(builder: (_, watch, __) { | |
return MaterialApp( | |
debugShowCheckedModeBanner: false, | |
title: 'Flutter Demo', | |
theme: ThemeData.from(colorScheme: const ColorScheme.light()), | |
darkTheme: ThemeData.from(colorScheme: const ColorScheme.dark()), | |
themeMode: watch(darkTheme.state) ? ThemeMode.dark : ThemeMode.light, | |
home: TodoPage(title: 'YATA - Yet Another Todo App'), | |
); | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
version: 1.0.0+1 | |
environment: | |
sdk: ">=2.12.0 <3.0.0" | |
dependencies: | |
flutter: | |
sdk: flutter | |
# json_annotation: ^4.0.0 # uncomment for json | |
hive: ^2.0.0 | |
hive_flutter: ^1.0.0 | |
flutter_riverpod: ^0.13.1 | |
dev_dependencies: | |
flutter_test: | |
sdk: flutter | |
# build_runner: ^1.0.0 # uncomment for json | |
# json_serializable: ^4.0.0 # uncomment for json | |
# linter: ^1.0.0 | |
# lint: ^1.5.3 | |
flutter: | |
uses-material-design: true |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* lib/types/todo.dart */ | |
// import 'package:json_annotation/json_annotation.dart'; | |
// part 'todo.g.dart'; | |
// @JsonSerializable() // uncomment for json | |
class Todo { | |
static int _id = 0; | |
final int id; | |
bool done; | |
String? content; | |
Todo({int? id, this.content, this.done = false}) : id = id != null ? (_id = _id < id ? id : _id + 1) : _id++; | |
// uncomment for json | |
// Map<String, dynamic> toJson() => _$TodoToJson(this); | |
// static const fromJson = _$TodoFromJson; | |
@override | |
String toString() => 'Todo(id: $id, done: $done, content: $content)'; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter_riverpod/flutter_riverpod.dart'; | |
import '../state/dark.dart'; | |
import '../state/todos.dart'; | |
import '../types/todo.dart'; | |
final klist = GlobalKey<AnimatedListState>(); | |
/// Group together transitions for this page. Notice that the key is required. | |
Widget sizeFadeTransition({required Widget child, required Animation<double> anim, required Key key}) => SizeTransition( | |
key: key, | |
sizeFactor: anim, | |
child: FadeTransition(opacity: anim, child: child), | |
); | |
class TodoPage extends StatelessWidget { | |
TodoPage({Key? key, required this.title, this.duration = const Duration(milliseconds: 150)}) : super(key: key); | |
final String title; | |
final Duration duration; | |
static const hint = "Let's do something! 💪︎"; | |
/// The cache for widgets whose index and content has not changed. | |
final cache = <int, Widget?>{}; | |
@override | |
Widget build(BuildContext bc) { | |
return Scaffold( | |
appBar: AppBar( | |
title: Text(title), | |
actions: [ | |
IconButton( | |
icon: const Icon(Icons.clear_all), | |
onPressed: () => bc.read(todos).clear(), | |
), | |
// A minimal example usage of a self-contained Consumer. | |
Consumer( | |
builder: (bc, watch, _) => Switch.adaptive( | |
value: watch(darkTheme.state), | |
onChanged: (val) => bc.read(darkTheme).state = val, | |
), | |
) | |
], | |
), | |
floatingActionButton: FloatingActionButton( | |
onPressed: () { | |
final idx = bc.read(todos).add(Todo()); | |
klist.currentState?.insertItem(idx, duration: duration); | |
}, | |
child: const Icon(Icons.add), | |
), | |
body: SafeArea( | |
child: Consumer( | |
builder: (bc, watch, _) { | |
// todoNotEmpty (a Provider<bool>) will only force an update when | |
// the underlying state changes such that the == comparison fails. | |
// (as of Riverpod v0.13.1) | |
// For this reason, it is good practice to overload == for all | |
// classes that you write. | |
final isNotEmpty = watch(todoNotEmpty); | |
return AnimatedSwitcher( | |
duration: duration, | |
// A Visibility is used here, | |
// but a simple ternary expression might suffice. | |
child: Visibility( | |
// The key here helps AnimatedSwitcher know that | |
// this widget has changed. | |
key: ValueKey(isNotEmpty), | |
visible: isNotEmpty, | |
replacement: Center(child: Text(hint, style: Theme.of(bc).textTheme.headline4)), | |
child: AnimatedList( | |
key: klist, | |
// This is always 1, because when isNotEmpty updates | |
// the todo list is guaranteed to have finished | |
// adding its first element. | |
initialItemCount: 1, | |
itemBuilder: (bc, idx, anim) { | |
// itemBuilder is called for *every* index and/or length | |
// change. Therefore, a naïve implementation of a widget | |
// cache is used here to minimize updating work. | |
// This is optional. | |
var child = cache[idx] as TodoListItem?; | |
final thisTodo = bc.read(todos.state)[idx]; | |
final changed = child?.idx != idx || child?.todo != thisTodo; | |
if (changed) { | |
child = TodoListItem(idx, thisTodo, duration); | |
cache[idx] = child; | |
} | |
return sizeFadeTransition( | |
// This is *key* to a smooth, self-updating list! | |
// Put a key in the immediate first widget of the builder. | |
key: ValueKey(thisTodo.id), | |
anim: anim, | |
child: child!, | |
); | |
}, | |
), | |
), | |
); | |
}, | |
), | |
), | |
); | |
} | |
} | |
/// This class is separate from [TodoTile], since we will need to provide | |
/// [AnimatedList] with a dummy corpse when we delete a todo. In other words, | |
/// this class defines the functionality, and [TodoTile] the look. | |
class TodoListItem extends StatefulWidget { | |
const TodoListItem(this.idx, this.todo, this.duration, {Key? key}) : super(key: key); | |
final int idx; | |
final Duration duration; | |
final Todo todo; | |
@override | |
_TodoListItemState createState() => _TodoListItemState(); | |
} | |
class _TodoListItemState extends State<TodoListItem> { | |
int prevlen = 0; | |
bool editing = true; | |
late Todo _todo; | |
late TextEditingController ctl; | |
final fn = FocusNode(); | |
@override | |
void initState() { | |
super.initState(); | |
_todo = widget.todo; | |
ctl = TextEditingController(text: _todo.content); | |
} | |
@override | |
void dispose() { | |
ctl.dispose(); | |
fn.dispose(); | |
super.dispose(); | |
} | |
Todo get todo => _todo; | |
/// Updates the store and triggers a [setState]. | |
set todo(Todo value) { | |
context.read(todos).put(widget.idx, value); | |
setState(() => _todo = value); | |
} | |
void doneEditing() { | |
editing = false; | |
todo = todo..content = ctl.text; | |
} | |
void insertTodo() { | |
doneEditing(); | |
context.read(todos).insert(widget.idx + 1, Todo()); | |
klist.currentState?.insertItem(widget.idx + 1, duration: widget.duration); | |
} | |
void removeTodo() { | |
klist.currentState?.removeItem( | |
widget.idx, | |
// Instead of passing a TodoListItem like in itemBuilder, we just need to | |
// display a corpse for the duration of the animation. | |
(_, anim) => sizeFadeTransition( | |
key: ValueKey(todo.id), | |
anim: anim, | |
child: TodoTile(todo: todo, dead: true), | |
), | |
duration: widget.duration, | |
); | |
context.read(todos).removeAt(widget.idx); | |
} | |
/// Returns whether the event has been handled | |
/// and should not propagate further. | |
bool handleKey(RawKeyEvent event) { | |
if (event.isKeyPressed(LogicalKeyboardKey.space) && ctl.text.length == prevlen /* Fixes a deviant space key */) { | |
ctl.value = TextEditingValue( | |
text: "${ctl.text} ", | |
selection: TextSelection.collapsed(offset: ctl.selection.extentOffset + 1), | |
); | |
return true; | |
} else if (event.isKeyPressed(LogicalKeyboardKey.enter) && !event.isAltPressed) { | |
insertTodo(); | |
return true; | |
} else if (event.isControlPressed && event.isKeyPressed(LogicalKeyboardKey.backspace)) { | |
final text = (ctl.text.split(" ")..length -= 1).join(" "); | |
ctl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)); | |
return true; | |
} else if (event.isKeyPressed(LogicalKeyboardKey.backspace) && ctl.text.isEmpty && prevlen == 0) { | |
removeTodo(); | |
return true; | |
} else if (event.isAltPressed && event.isKeyPressed(LogicalKeyboardKey.keyJ)) { | |
todo = todo..done = !todo.done; | |
return true; | |
} else if (event.isKeyPressed(LogicalKeyboardKey.escape)) { | |
doneEditing(); | |
return true; | |
} | |
return false; | |
} | |
@override | |
Widget build(BuildContext bc) { | |
return FocusScope( | |
// Handles some of the keypresses here, since some of them ended up being | |
// passed to the ListTile down below, which is undesirable. Try removing | |
// FocusScope and see what happens. | |
onKey: (_, event) => handleKey(event), | |
child: TodoTile( | |
todo: todo, | |
editing: editing, | |
titleBuilder: (_) => TextField( | |
autofocus: true, | |
minLines: 1, | |
maxLines: 2, | |
controller: ctl, | |
focusNode: fn, | |
textInputAction: TextInputAction.next, | |
decoration: const InputDecoration.collapsed(hintText: TodoTile.hint), | |
onChanged: (val) => prevlen = ctl.text.length, | |
onTap: doneEditing, | |
onEditingComplete: insertTodo, | |
), | |
onTap: () => setState(() => editing = true), | |
onAdd: insertTodo, | |
onChange: (val) { | |
bc.read(todos).put(widget.idx, val); | |
todo = val; | |
}, | |
onDelete: removeTodo, | |
), | |
); | |
} | |
} | |
class TodoTile extends StatelessWidget { | |
const TodoTile({ | |
Key? key, | |
required Todo todo, | |
this.titleBuilder, | |
this.onTap, | |
this.onAdd, | |
void Function(Todo)? onChange, | |
this.onDelete, | |
this.dead = false, | |
this.editing = false, | |
}) : _todo = todo, | |
_onChange = onChange, | |
super(key: key); | |
final Todo _todo; | |
/// If true, returns a corpse of this title that can be passed | |
/// as the argument to an [AnimatedList]'s `removeItem` method. | |
final bool dead; | |
/// If true, skips calling [titleBuilder] and returns | |
/// a plain [Text] object for the title. Overridden by [dead]. | |
final bool editing; | |
final void Function(Todo)? _onChange; | |
final void Function()? onTap; | |
final void Function()? onAdd; | |
final void Function()? onDelete; | |
/// Builds the title for the inner [ListTile], usually a [TextField]. | |
final Widget Function(BuildContext)? titleBuilder; | |
static const hint = 'What to do next?'; | |
static void noop() {} | |
Todo get todo => _todo; | |
set todo(Todo todo) => _onChange?.call(todo); | |
@override | |
Widget build(BuildContext bc) { | |
final isEmpty = todo.content?.isEmpty ?? true; | |
return ListTile( | |
title: !editing || dead | |
? Text( | |
isEmpty ? hint : todo.content!, | |
style: isEmpty ? TextStyle(color: Theme.of(bc).hintColor) : null, | |
maxLines: 2, | |
overflow: TextOverflow.ellipsis, | |
) | |
: titleBuilder?.call(bc) ?? Container(), | |
onTap: editing || dead ? null : onTap, | |
leading: Checkbox( | |
value: todo.done, | |
onChanged: dead ? (_) {} : (val) => todo = todo..done = val!, | |
), | |
trailing: Row( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
IconButton(icon: const Icon(Icons.add), onPressed: dead ? noop : onAdd), | |
IconButton(icon: const Icon(Icons.done), onPressed: dead ? noop : onDelete), | |
], | |
), | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter_riverpod/flutter_riverpod.dart'; | |
import '../types/todo.dart'; | |
class ListStore<T> extends StateNotifier<List<T>> { | |
ListStore([List<T>? seed]) : super(seed ?? []); | |
int add(T t) { | |
final len = state.length; | |
state = state..add(t); | |
return len; | |
} | |
void clear() => state = state..length = 0; | |
void insert(int idx, T t) => state = state..insert(idx, t); | |
void removeAt(int idx) => state = state..removeAt(idx); | |
void put(int idx, T t) { | |
if (idx >= state.length) return; | |
state = state..[idx] = t; | |
} | |
T mutate(int idx, T Function(T) mutator) { | |
final mutated = mutator(state[idx]); | |
state = state..[idx] = mutated; | |
return mutated; | |
} | |
} | |
final todos = StateNotifierProvider<ListStore<Todo>>((_) => ListStore()); | |
/// Caches the non-empty state of [todos] here. | |
final todoNotEmpty = Provider((ref) => ref.watch(todos.state).isNotEmpty); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment