Initial commit: Flutter todo app with Riverpod
Some checks failed
CI / Static Analysis (push) Failing after 2m7s
CI / Tests (push) Has been skipped

This commit is contained in:
PeterChrz 2026-08-04 23:40:54 -04:00
commit 514adde721
Signed by untrusted user who does not match committer: pch
GPG key ID: 8F0826ECF7302C63
25 changed files with 1769 additions and 0 deletions

35
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
analyze:
name: Static Analysis
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/flutter:3.24.5
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: flutter pub get
- name: Check formatting
run: dart format --set-exit-if-changed lib/ test/
- name: Analyze
run: flutter analyze
test:
name: Tests
runs-on: ubuntu-latest
needs: analyze
container:
image: ghcr.io/cirruslabs/flutter:3.24.5
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: flutter pub get
- name: Run tests
run: flutter test

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Flutter
.dart_tool/
.packages
build/
*.iml
.flutter-plugins
.flutter-plugins-dependencies
.metadata
# Android
android/app/google-services.json
android/key.properties
*.jks
*.keystore
# iOS
ios/Pods/
ios/Runner/GoogleService-Info.plist
# Generated
*.g.dart
*.freezed.dart
*.mocks.dart
# IDE
.idea/
.vscode/
*.swp
*.swo

27
analysis_options.yaml Normal file
View file

@ -0,0 +1,27 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
- always_declare_return_types
- annotate_overrides
- avoid_empty_else
- avoid_print
- avoid_unnecessary_containers
- avoid_web_libraries_in_flutter
- no_logic_in_create_state
- prefer_const_constructors
- prefer_const_declarations
- prefer_final_locals
- prefer_single_quotes
- sort_child_properties_last
- unawaited_futures
- use_build_context_synchronously
- use_key_in_widget_constructors
analyzer:
errors:
missing_return: error
dead_code: warning
exclude:
- '**/*.g.dart'
- '**/*.freezed.dart'

23
lib/app.dart Normal file
View file

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/routing/app_router.dart';
import 'core/theme/app_theme.dart';
class TodoApp extends ConsumerWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Todo App',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
routerConfig: router,
debugShowCheckedModeBanner: false,
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/todo/presentation/screens/todo_detail_screen.dart';
import '../../features/todo/presentation/screens/todo_list_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
name: 'todo-list',
builder: (context, state) => const TodoListScreen(),
),
GoRoute(
path: '/todo/new',
name: 'todo-create',
builder: (context, state) => const TodoDetailScreen(),
),
GoRoute(
path: '/todo/:id',
name: 'todo-detail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return TodoDetailScreen(todoId: id);
},
),
],
);
});

View file

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class AppColors {
AppColors._();
static const priorityHigh = Color(0xFFBA1A1A);
static const priorityMedium = Color(0xFF8A6100);
static const priorityLow = Color(0xFF386A20);
static Color priorityColor(String priority) {
return switch (priority) {
'high' => priorityHigh,
'medium' => priorityMedium,
'low' => priorityLow,
_ => priorityMedium,
};
}
}

View file

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class AppTheme {
AppTheme._();
static const _seedColor = Color(0xFF6750A4);
static final light = ThemeData(
colorSchemeSeed: _seedColor,
useMaterial3: true,
brightness: Brightness.light,
);
static final dark = ThemeData(
colorSchemeSeed: _seedColor,
useMaterial3: true,
brightness: Brightness.dark,
);
}

View file

@ -0,0 +1,89 @@
import 'package:uuid/uuid.dart';
import '../../domain/entities/todo.dart';
import '../../domain/entities/create_todo_params.dart';
import '../../domain/entities/update_todo_params.dart';
import '../../domain/repositories/todo_repository.dart';
class InMemoryTodoRepository implements TodoRepository {
final _uuid = const Uuid();
final List<Todo> _todos = [];
@override
Future<List<Todo>> getTodos({String? priorityFilter, bool? completedFilter}) async {
var result = List<Todo>.from(_todos);
if (priorityFilter != null) {
result = result.where((t) => t.priority == priorityFilter).toList();
}
if (completedFilter != null) {
result = result.where((t) => t.isCompleted == completedFilter).toList();
}
result.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return result;
}
@override
Future<Todo> getTodo(String id) async {
final index = _todos.indexWhere((t) => t.id == id);
if (index == -1) throw Exception('Todo not found: $id');
return _todos[index];
}
@override
Future<Todo> createTodo(CreateTodoParams params) async {
final now = DateTime.now();
final todo = Todo(
id: _uuid.v4(),
title: params.title,
description: params.description,
priority: params.priority,
dueDate: params.dueDate,
sortOrder: _todos.length,
createdAt: now,
updatedAt: now,
);
_todos.add(todo);
return todo;
}
@override
Future<Todo> updateTodo(String id, UpdateTodoParams params) async {
final index = _todos.indexWhere((t) => t.id == id);
if (index == -1) throw Exception('Todo not found: $id');
final existing = _todos[index];
final updated = existing.copyWith(
title: params.title ?? existing.title,
description: params.description,
isCompleted: params.isCompleted ?? existing.isCompleted,
priority: params.priority ?? existing.priority,
dueDate: params.dueDate,
sortOrder: params.sortOrder ?? existing.sortOrder,
updatedAt: DateTime.now(),
);
_todos[index] = updated;
return updated;
}
@override
Future<void> deleteTodo(String id) async {
_todos.removeWhere((t) => t.id == id);
}
@override
Future<Todo> toggleTodo(String id) async {
final todo = await getTodo(id);
return updateTodo(id, UpdateTodoParams(isCompleted: !todo.isCompleted));
}
@override
Future<void> reorderTodos(int oldIndex, int newIndex) async {
final item = _todos.removeAt(oldIndex);
_todos.insert(newIndex, item);
for (var i = 0; i < _todos.length; i++) {
_todos[i] = _todos[i].copyWith(sortOrder: i);
}
}
}

View file

@ -0,0 +1,13 @@
class CreateTodoParams {
final String title;
final String? description;
final String priority;
final DateTime? dueDate;
const CreateTodoParams({
required this.title,
this.description,
this.priority = 'medium',
this.dueDate,
});
}

View file

@ -0,0 +1,86 @@
class Todo {
final String id;
final String title;
final String? description;
final bool isCompleted;
final String priority;
final DateTime? dueDate;
final int sortOrder;
final DateTime? createdAt;
final DateTime? updatedAt;
const Todo({
required this.id,
required this.title,
this.description,
this.isCompleted = false,
this.priority = 'medium',
this.dueDate,
this.sortOrder = 0,
this.createdAt,
this.updatedAt,
});
Todo copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
String? priority,
DateTime? dueDate,
int? sortOrder,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
priority: priority ?? this.priority,
dueDate: dueDate ?? this.dueDate,
sortOrder: sortOrder ?? this.sortOrder,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
'priority': priority,
'dueDate': dueDate?.toIso8601String(),
'sortOrder': sortOrder,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
isCompleted: json['isCompleted'] as bool? ?? false,
priority: json['priority'] as String? ?? 'medium',
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate'] as String) : null,
sortOrder: json['sortOrder'] as int? ?? 0,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Todo && id == other.id;
@override
int get hashCode => id.hashCode;
@override
String toString() => 'Todo(id: $id, title: $title, isCompleted: $isCompleted)';
}

View file

@ -0,0 +1,17 @@
class UpdateTodoParams {
final String? title;
final String? description;
final bool? isCompleted;
final String? priority;
final DateTime? dueDate;
final int? sortOrder;
const UpdateTodoParams({
this.title,
this.description,
this.isCompleted,
this.priority,
this.dueDate,
this.sortOrder,
});
}

View file

@ -0,0 +1,13 @@
import '../entities/todo.dart';
import '../entities/create_todo_params.dart';
import '../entities/update_todo_params.dart';
abstract class TodoRepository {
Future<List<Todo>> getTodos({String? priorityFilter, bool? completedFilter});
Future<Todo> getTodo(String id);
Future<Todo> createTodo(CreateTodoParams params);
Future<Todo> updateTodo(String id, UpdateTodoParams params);
Future<void> deleteTodo(String id);
Future<Todo> toggleTodo(String id);
Future<void> reorderTodos(int oldIndex, int newIndex);
}

View file

@ -0,0 +1,86 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/in_memory_todo_repository.dart';
import '../../domain/entities/todo.dart';
import '../../domain/entities/create_todo_params.dart';
import '../../domain/entities/update_todo_params.dart';
import '../../domain/repositories/todo_repository.dart';
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
return InMemoryTodoRepository();
});
final todoListProvider =
AsyncNotifierProvider<TodoListNotifier, List<Todo>>(
TodoListNotifier.new,
);
final todoDetailProvider =
FutureProvider.family<Todo, String>((ref, id) {
return ref.watch(todoRepositoryProvider).getTodo(id);
});
final todoFilterProvider = StateProvider<TodoFilter>((ref) {
return const TodoFilter();
});
class TodoFilter {
final String? priority;
final bool? completed;
const TodoFilter({this.priority, this.completed});
TodoFilter copyWith({String? priority, bool? completed, bool clearPriority = false, bool clearCompleted = false}) {
return TodoFilter(
priority: clearPriority ? null : (priority ?? this.priority),
completed: clearCompleted ? null : (completed ?? this.completed),
);
}
}
class TodoListNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async {
final repository = ref.watch(todoRepositoryProvider);
final filter = ref.watch(todoFilterProvider);
return repository.getTodos(
priorityFilter: filter.priority,
completedFilter: filter.completed,
);
}
Future<void> createTodo(CreateTodoParams params) async {
final repository = ref.read(todoRepositoryProvider);
await repository.createTodo(params);
ref.invalidateSelf();
}
Future<void> updateTodo(String id, UpdateTodoParams params) async {
final repository = ref.read(todoRepositoryProvider);
await repository.updateTodo(id, params);
ref.invalidateSelf();
}
Future<void> deleteTodo(String id) async {
final repository = ref.read(todoRepositoryProvider);
await repository.deleteTodo(id);
ref.invalidateSelf();
}
Future<void> toggleTodo(String id) async {
final repository = ref.read(todoRepositoryProvider);
await repository.toggleTodo(id);
ref.invalidateSelf();
}
Future<void> reorderTodos(int oldIndex, int newIndex) async {
final repository = ref.read(todoRepositoryProvider);
await repository.reorderTodos(oldIndex, newIndex);
ref.invalidateSelf();
}
void setFilter(TodoFilter filter) {
ref.read(todoFilterProvider.notifier).state = filter;
ref.invalidateSelf();
}
}

View file

@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../domain/entities/create_todo_params.dart';
import '../../domain/entities/update_todo_params.dart';
import '../providers/todo_providers.dart';
class TodoDetailScreen extends ConsumerStatefulWidget {
final String? todoId;
const TodoDetailScreen({super.key, this.todoId});
@override
ConsumerState<TodoDetailScreen> createState() => _TodoDetailScreenState();
}
class _TodoDetailScreenState extends ConsumerState<TodoDetailScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _titleController;
late final TextEditingController _descriptionController;
String _priority = 'medium';
DateTime? _dueDate;
bool _isLoading = false;
bool get isEditing => widget.todoId != null;
@override
void initState() {
super.initState();
_titleController = TextEditingController();
_descriptionController = TextEditingController();
if (isEditing) {
_loadTodo();
}
}
Future<void> _loadTodo() async {
final todoAsync = ref.read(todoDetailProvider(widget.todoId!));
todoAsync.whenData((todo) {
_titleController.text = todo.title;
_descriptionController.text = todo.description ?? '';
_priority = todo.priority;
_dueDate = todo.dueDate;
});
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
if (isEditing) {
await ref.read(todoListProvider.notifier).updateTodo(
widget.todoId!,
UpdateTodoParams(
title: _titleController.text,
description: _descriptionController.text.isEmpty
? null
: _descriptionController.text,
priority: _priority,
dueDate: _dueDate,
),
);
} else {
await ref.read(todoListProvider.notifier).createTodo(
CreateTodoParams(
title: _titleController.text,
description: _descriptionController.text.isEmpty
? null
: _descriptionController.text,
priority: _priority,
dueDate: _dueDate,
),
);
}
if (mounted) context.pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _pickDueDate() async {
final date = await showDatePicker(
context: context,
initialDate: _dueDate ?? DateTime.now().add(const Duration(days: 1)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date != null) {
setState(() => _dueDate = date);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Edit Todo' : 'New Todo'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Title is required';
}
return null;
},
autofocus: !isEditing,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'low', child: Text('Low')),
DropdownMenuItem(value: 'medium', child: Text('Medium')),
DropdownMenuItem(value: 'high', child: Text('High')),
],
onChanged: (value) {
if (value != null) setState(() => _priority = value);
},
),
const SizedBox(height: 16),
InkWell(
onTap: _pickDueDate,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Due Date (optional)',
border: OutlineInputBorder(),
suffixIcon: Icon(Icons.calendar_today),
),
child: Text(
_dueDate != null
? '${_dueDate!.year}-${_dueDate!.month.toString().padLeft(2, '0')}-${_dueDate!.day.toString().padLeft(2, '0')}'
: 'No date selected',
style: theme.textTheme.bodyLarge?.copyWith(
color: _dueDate != null ? null : theme.colorScheme.onSurfaceVariant,
),
),
),
),
if (_dueDate != null)
TextButton(
onPressed: () => setState(() => _dueDate = null),
child: const Text('Clear date'),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _save,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isEditing ? 'Save Changes' : 'Create Todo'),
),
],
),
),
);
}
}

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/todo_providers.dart';
import '../widgets/todo_list_tile.dart';
import '../widgets/todo_filter_bar.dart';
import '../widgets/empty_todo_state.dart';
class TodoListScreen extends ConsumerWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todoListProvider);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
centerTitle: false,
),
body: Column(
children: [
const TodoFilterBar(),
Expanded(
child: todosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 8),
Text('Something went wrong', style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => ref.invalidate(todoListProvider),
child: const Text('Retry'),
),
],
),
),
data: (todos) {
if (todos.isEmpty) {
return const EmptyTodoState();
}
return ReorderableListView.builder(
itemCount: todos.length,
onReorderItem: (oldIndex, newIndex) {
ref.read(todoListProvider.notifier).reorderTodos(oldIndex, newIndex);
},
itemBuilder: (context, index) {
return TodoListTile(
key: ValueKey(todos[index].id),
todo: todos[index],
);
},
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.go('/todo/new'),
child: const Icon(Icons.add),
),
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class EmptyTodoState extends StatelessWidget {
const EmptyTodoState({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.checklist_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No todos yet',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Tap + to create your first todo',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.push('/todo/new'),
child: const Text('Create Todo'),
),
],
),
);
}
}

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_providers.dart';
class TodoFilterBar extends ConsumerWidget {
const TodoFilterBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(todoFilterProvider);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
FilterChip(
label: const Text('All'),
selected: filter.priority == null && filter.completed == null,
onSelected: (_) {
ref.read(todoListProvider.notifier).setFilter(const TodoFilter());
},
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Active'),
selected: filter.completed == false,
onSelected: (_) {
ref.read(todoListProvider.notifier).setFilter(
filter.copyWith(completed: false, clearPriority: true),
);
},
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Completed'),
selected: filter.completed == true,
onSelected: (_) {
ref.read(todoListProvider.notifier).setFilter(
filter.copyWith(completed: true, clearPriority: true),
);
},
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(
Icons.filter_list,
color: filter.priority != null
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
onSelected: (priority) {
ref.read(todoListProvider.notifier).setFilter(
filter.copyWith(priority: priority, clearCompleted: true),
);
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'high', child: Text('High Priority')),
const PopupMenuItem(value: 'medium', child: Text('Medium Priority')),
const PopupMenuItem(value: 'low', child: Text('Low Priority')),
],
),
],
),
);
}
}

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../domain/entities/todo.dart';
import '../providers/todo_providers.dart';
class TodoListTile extends ConsumerWidget {
final Todo todo;
const TodoListTile({super.key, required this.todo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final priorityColor = AppColors.priorityColor(todo.priority);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) {
ref.read(todoListProvider.notifier).toggleTodo(todo.id);
},
),
title: Text(
todo.title,
style: theme.textTheme.titleMedium?.copyWith(
decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
color: todo.isCompleted ? theme.colorScheme.onSurfaceVariant : null,
),
),
subtitle: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: priorityColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
todo.priority.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: priorityColor,
fontWeight: FontWeight.bold,
),
),
),
if (todo.dueDate != null) ...[
const SizedBox(width: 8),
Icon(
Icons.calendar_today,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${todo.dueDate!.month}/${todo.dueDate!.day}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
ref.read(todoListProvider.notifier).deleteTodo(todo.id);
},
),
onTap: () => context.push('/todo/${todo.id}'),
),
);
}
}

12
lib/main.dart Normal file
View file

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
runApp(
const ProviderScope(
child: TodoApp(),
),
);
}

298
pubspec.lock Normal file
View file

@ -0,0 +1,298 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "14.8.1"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.4.2"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.24.0"

27
pubspec.yaml Normal file
View file

@ -0,0 +1,27 @@
name: todo_app
description: A simple todo app built with Flutter and Riverpod.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.5.0 <4.0.0'
flutter: '>=3.24.0'
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
go_router: ^14.6.2
uuid: ^4.5.1
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
mocktail: ^1.0.4
flutter:
uses-material-design: true
assets:
- assets/images/

View file

@ -0,0 +1,118 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:todo_app/features/todo/domain/entities/todo.dart';
import 'package:todo_app/features/todo/domain/entities/create_todo_params.dart';
import 'package:todo_app/features/todo/domain/repositories/todo_repository.dart';
import 'package:todo_app/features/todo/presentation/providers/todo_providers.dart';
class MockTodoRepository extends Mock implements TodoRepository {}
class FakeCreateTodoParams extends Fake implements CreateTodoParams {}
void main() {
late MockTodoRepository repository;
late ProviderContainer container;
final testTodo = Todo(
id: '1',
title: 'Test Todo',
priority: 'medium',
sortOrder: 0,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
setUpAll(() {
registerFallbackValue(FakeCreateTodoParams());
});
setUp(() {
repository = MockTodoRepository();
container = ProviderContainer(overrides: [
todoRepositoryProvider.overrideWithValue(repository),
]);
addTearDown(container.dispose);
});
group('todoListProvider', () {
test('builds with todo list from repository', () async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => [testTodo]);
await container.read(todoListProvider.future);
expect(container.read(todoListProvider).value, equals([testTodo]));
});
test('handles error state', () async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenThrow(Exception('Failed'));
try {
await container.read(todoListProvider.future);
fail('Should have thrown');
} catch (_) {
expect(container.read(todoListProvider).hasError, isTrue);
}
});
});
group('todoDetailProvider', () {
test('returns todo by id', () async {
when(() => repository.getTodo('1')).thenAnswer((_) async => testTodo);
final result = await container.read(todoDetailProvider('1').future);
expect(result, equals(testTodo));
});
});
group('TodoListNotifier.createTodo', () {
test('creates todo and refreshes list', () async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => [testTodo]);
when(() => repository.createTodo(any())).thenAnswer((_) async => testTodo);
await container.read(todoListProvider.notifier).createTodo(
const CreateTodoParams(title: 'New'),
);
verify(() => repository.createTodo(any())).called(1);
});
});
group('TodoListNotifier.toggleTodo', () {
test('toggles and refreshes', () async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => [testTodo]);
when(() => repository.toggleTodo('1')).thenAnswer((_) async => testTodo);
await container.read(todoListProvider.notifier).toggleTodo('1');
verify(() => repository.toggleTodo('1')).called(1);
});
});
group('TodoListNotifier.deleteTodo', () {
test('deletes and refreshes', () async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => []);
when(() => repository.deleteTodo('1')).thenAnswer((_) async {});
await container.read(todoListProvider.notifier).deleteTodo('1');
verify(() => repository.deleteTodo('1')).called(1);
});
});
}

View file

@ -0,0 +1,183 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo_app/features/todo/data/repositories/in_memory_todo_repository.dart';
import 'package:todo_app/features/todo/domain/entities/create_todo_params.dart';
import 'package:todo_app/features/todo/domain/entities/update_todo_params.dart';
void main() {
late InMemoryTodoRepository repository;
setUp(() {
repository = InMemoryTodoRepository();
});
group('createTodo', () {
test('creates a todo with required fields', () async {
final todo = await repository.createTodo(
const CreateTodoParams(title: 'Test Todo'),
);
expect(todo.title, equals('Test Todo'));
expect(todo.isCompleted, isFalse);
expect(todo.priority, equals('medium'));
expect(todo.id, isNotEmpty);
expect(todo.createdAt, isNotNull);
});
test('creates a todo with all fields', () async {
final dueDate = DateTime(2026, 12, 31);
final todo = await repository.createTodo(
CreateTodoParams(
title: 'Full Todo',
description: 'A description',
priority: 'high',
dueDate: dueDate,
),
);
expect(todo.title, equals('Full Todo'));
expect(todo.description, equals('A description'));
expect(todo.priority, equals('high'));
expect(todo.dueDate, equals(dueDate));
});
});
group('getTodos', () {
test('returns empty list initially', () async {
final todos = await repository.getTodos();
expect(todos, isEmpty);
});
test('returns all todos sorted by sortOrder', () async {
await repository.createTodo(const CreateTodoParams(title: 'B'));
await repository.createTodo(const CreateTodoParams(title: 'A'));
final todos = await repository.getTodos();
expect(todos.length, equals(2));
expect(todos[0].title, equals('B'));
expect(todos[1].title, equals('A'));
});
test('filters by priority', () async {
await repository.createTodo(
const CreateTodoParams(title: 'High', priority: 'high'),
);
await repository.createTodo(
const CreateTodoParams(title: 'Low', priority: 'low'),
);
final highTodos = await repository.getTodos(priorityFilter: 'high');
expect(highTodos.length, equals(1));
expect(highTodos.first.title, equals('High'));
});
test('filters by completion status', () async {
final todo = await repository.createTodo(
const CreateTodoParams(title: 'Done'),
);
await repository.createTodo(
const CreateTodoParams(title: 'Pending'),
);
await repository.toggleTodo(todo.id);
final completed = await repository.getTodos(completedFilter: true);
expect(completed.length, equals(1));
expect(completed.first.title, equals('Done'));
});
});
group('getTodo', () {
test('returns todo by id', () async {
final created = await repository.createTodo(
const CreateTodoParams(title: 'Find Me'),
);
final found = await repository.getTodo(created.id);
expect(found.title, equals('Find Me'));
});
test('throws when not found', () async {
expect(
() => repository.getTodo('nonexistent'),
throwsA(isA<Exception>()),
);
});
});
group('updateTodo', () {
test('updates todo fields', () async {
final created = await repository.createTodo(
const CreateTodoParams(title: 'Original'),
);
final updated = await repository.updateTodo(
created.id,
const UpdateTodoParams(title: 'Updated', priority: 'high'),
);
expect(updated.title, equals('Updated'));
expect(updated.priority, equals('high'));
expect(updated.updatedAt, isNotNull);
});
test('partial update preserves other fields', () async {
final created = await repository.createTodo(
const CreateTodoParams(title: 'Original', priority: 'low'),
);
final updated = await repository.updateTodo(
created.id,
const UpdateTodoParams(title: 'Renamed'),
);
expect(updated.title, equals('Renamed'));
expect(updated.priority, equals('low'));
});
});
group('toggleTodo', () {
test('toggles completion status', () async {
final created = await repository.createTodo(
const CreateTodoParams(title: 'Toggle'),
);
expect(created.isCompleted, isFalse);
final toggled = await repository.toggleTodo(created.id);
expect(toggled.isCompleted, isTrue);
final untoggled = await repository.toggleTodo(created.id);
expect(untoggled.isCompleted, isFalse);
});
});
group('deleteTodo', () {
test('removes todo', () async {
final created = await repository.createTodo(
const CreateTodoParams(title: 'Delete Me'),
);
await repository.deleteTodo(created.id);
final todos = await repository.getTodos();
expect(todos, isEmpty);
});
});
group('reorderTodos', () {
test('reorders todos and updates sortOrder', () async {
await repository.createTodo(const CreateTodoParams(title: 'A'));
await repository.createTodo(const CreateTodoParams(title: 'B'));
await repository.createTodo(const CreateTodoParams(title: 'C'));
await repository.reorderTodos(0, 2);
final todos = await repository.getTodos();
expect(todos[0].title, equals('B'));
expect(todos[1].title, equals('C'));
expect(todos[2].title, equals('A'));
expect(todos[0].sortOrder, equals(0));
expect(todos[1].sortOrder, equals(1));
expect(todos[2].sortOrder, equals(2));
});
});
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:todo_app/features/todo/domain/entities/todo.dart';
import 'package:todo_app/features/todo/domain/repositories/todo_repository.dart';
import 'package:todo_app/features/todo/presentation/providers/todo_providers.dart';
import 'package:todo_app/features/todo/presentation/screens/todo_detail_screen.dart';
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
late MockTodoRepository repository;
final testTodo = Todo(
id: '1',
title: 'Existing Todo',
description: 'A description',
priority: 'high',
sortOrder: 0,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
Widget createCreateWidget() {
return ProviderScope(
overrides: [
todoRepositoryProvider.overrideWithValue(repository),
],
child: const MaterialApp(
home: TodoDetailScreen(),
),
);
}
Widget createEditWidget() {
return ProviderScope(
overrides: [
todoRepositoryProvider.overrideWithValue(repository),
],
child: const MaterialApp(
home: TodoDetailScreen(todoId: '1'),
),
);
}
setUp(() {
repository = MockTodoRepository();
});
group('Create mode', () {
testWidgets('shows create form', (tester) async {
await tester.pumpWidget(createCreateWidget());
await tester.pumpAndSettle();
expect(find.text('New Todo'), findsOneWidget);
expect(find.text('Create Todo'), findsOneWidget);
});
testWidgets('validates title is required', (tester) async {
await tester.pumpWidget(createCreateWidget());
await tester.pumpAndSettle();
await tester.tap(find.text('Create Todo'));
await tester.pumpAndSettle();
expect(find.text('Title is required'), findsOneWidget);
});
});
group('Edit mode', () {
testWidgets('loads existing todo data', (tester) async {
when(() => repository.getTodo('1')).thenAnswer((_) async => testTodo);
await tester.pumpWidget(createEditWidget());
await tester.pump();
await tester.pump();
expect(find.text('Edit Todo'), findsOneWidget);
});
});
}

View file

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:todo_app/features/todo/domain/entities/todo.dart';
import 'package:todo_app/features/todo/domain/repositories/todo_repository.dart';
import 'package:todo_app/features/todo/presentation/providers/todo_providers.dart';
import 'package:todo_app/features/todo/presentation/screens/todo_list_screen.dart';
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
late MockTodoRepository repository;
final testTodo = Todo(
id: '1',
title: 'Test Todo',
priority: 'medium',
sortOrder: 0,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
Widget createWidgetUnderTest() {
return ProviderScope(
overrides: [
todoRepositoryProvider.overrideWithValue(repository),
],
child: const MaterialApp(
home: TodoListScreen(),
),
);
}
setUp(() {
repository = MockTodoRepository();
});
testWidgets('shows loading indicator initially', (tester) async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => [testTodo]);
await tester.pumpWidget(createWidgetUnderTest());
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows todo list when loaded', (tester) async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => [testTodo]);
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
expect(find.text('Test Todo'), findsOneWidget);
expect(find.text('MEDIUM'), findsOneWidget);
});
testWidgets('shows empty state when no todos', (tester) async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => []);
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
expect(find.text('No todos yet'), findsOneWidget);
expect(find.text('Create Todo'), findsOneWidget);
});
testWidgets('shows error state with retry button', (tester) async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenThrow(Exception('Failed'));
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
expect(find.text('Something went wrong'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('has FAB to create new todo', (tester) async {
when(() => repository.getTodos(
priorityFilter: any(named: 'priorityFilter'),
completedFilter: any(named: 'completedFilter'),
)).thenAnswer((_) async => []);
await tester.pumpWidget(createWidgetUnderTest());
await tester.pumpAndSettle();
expect(find.byType(FloatingActionButton), findsOneWidget);
});
}