Skip to content

Instantly share code, notes, and snippets.

@Desdaemon
Last active March 15, 2021 13:52
Show Gist options
  • Save Desdaemon/ed9a867b806376ad62b06c05adee19cd to your computer and use it in GitHub Desktop.
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)
/* 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);
}
/* 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'),
);
});
}
}
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
/* 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)';
}
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),
],
),
);
}
}
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