Initial commit: two Clojure Android todo apps

Side-by-side comparison of viable Clojure-on-Android approaches:
- todo-expo/: ClojureScript + shadow-cljs + Expo + Reagent + re-frame
- todo-flutter/: ClojureDart + Flutter

Both apps feature: add/remove/check-off todos, SQLite persistence,
categories, priorities, edit support, swipe-to-delete, filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 23:41:45 -05:00
commit 453d017558
174 changed files with 27073 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
(ns todo.db
(:require
["package:sqflite/sqflite.dart" :as sq]
["package:path/path.dart" :as p]
["package:path_provider/path_provider.dart" :as pp]))
(def ^:private db-ref (atom nil))
(defn init-db! []
(let [dir (await (pp/getApplicationDocumentsDirectory))
db-path (p/join (.-path dir) "todos.db")
db (await (sq/openDatabase db-path
.version 1
.onCreate (fn [^sq/Database db version]
(await (.execute db
"CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
category TEXT DEFAULT 'Personal',
priority TEXT DEFAULT 'medium',
created_at INTEGER DEFAULT (strftime('%s','now'))
)")))))]
(reset! db-ref db)))
(defn load-todos! []
(when-let [^sq/Database db @db-ref]
(let [rows (await (.query db "todos" .orderBy "created_at DESC"))]
(mapv (fn [row]
{:id (get row "id")
:title (get row "title")
:completed (= 1 (get row "completed"))
:category (get row "category")
:priority (keyword (get row "priority"))
:created-at (get row "created_at")})
rows))))
(defn insert-todo! [title category priority]
(when-let [^sq/Database db @db-ref]
(let [id (await (.insert db "todos"
{"title" title
"category" category
"priority" (name priority)
"completed" 0}))]
{:id id
:title title
:completed false
:category category
:priority priority})))
(defn update-todo-db! [id title category priority]
(when-let [^sq/Database db @db-ref]
(await (.update db "todos"
{"title" title "category" category "priority" (name priority)}
.where "id = ?" .whereArgs [id]))))
(defn toggle-todo-db! [id completed]
(when-let [^sq/Database db @db-ref]
(await (.update db "todos"
{"completed" (if completed 1 0)}
.where "id = ?" .whereArgs [id]))))
(defn delete-todo-db! [id]
(when-let [^sq/Database db @db-ref]
(await (.delete db "todos"
.where "id = ?" .whereArgs [id]))))
+67
View File
@@ -0,0 +1,67 @@
(ns todo.main
(:require
["package:flutter/material.dart" :as m]
[cljd.flutter :as f]
[todo.state :as state]
[todo.db :as db]
[todo.widgets.todo-list :as todo-list]
[todo.widgets.todo-form :as todo-form]))
(defn filter-bar [current-filter counts]
(m/Padding
.padding (m/EdgeInsets.symmetric .horizontal 16 .vertical 8)
.child
(m/Row
.children
(into []
(map (fn [[filter-key label]]
(m/Padding
.padding (m/EdgeInsets.only .right 8)
.child
(m/FilterChip
.selected (= current-filter filter-key)
.label (m/Text (str label " (" (get counts filter-key 0) ")"))
.onSelected (fn [_] (state/set-filter! filter-key)))))
[[:all "All"] [:active "Active"] [:completed "Done"]])))))
(defn home-page []
(f/widget
:context ctx
:get {{^m/ColorScheme color-scheme .-colorScheme} m/Theme}
:watch [app-state state/app-state]
:let [current-filter (:filter app-state)
counts (state/get-counts app-state)
filtered-todos (state/get-filtered-todos app-state)]
(m/Scaffold
.appBar
(m/AppBar
.title (m/Text "My Todos")
.centerTitle false
.backgroundColor (.-primaryContainer color-scheme)
.foregroundColor (.-onPrimaryContainer color-scheme))
.body
(m/Column
.children
[(filter-bar current-filter counts)
(m/Expanded
.child (todo-list/todo-list-widget filtered-todos))])
.floatingActionButton
(m/FloatingActionButton
.onPressed (fn [] (todo-form/show-todo-dialog ctx nil))
.child (m/Icon m/Icons.add)))))
(defn main []
(await (db/init-db!))
(let [todos (await (db/load-todos!))]
(when todos
(state/set-todos! todos)))
(f/run
(m/MaterialApp
.title "ClojureDart Todo"
.debugShowCheckedModeBanner false
.theme (m/ThemeData
.colorSchemeSeed m/Colors.deepPurple
.useMaterial3 true
.brightness m/Brightness.light))
.home
(home-page)))
+60
View File
@@ -0,0 +1,60 @@
(ns todo.state)
(def categories ["Personal" "Work" "Shopping" "Health" "Other"])
(def priorities [:low :medium :high])
(def priority-colors
{:low 0xFF4CAF50
:medium 0xFFFF9800
:high 0xFFF44336})
(def app-state
(atom {:todos []
:filter :all}))
(defn set-todos! [todos]
(swap! app-state assoc :todos todos))
(defn add-todo! [todo]
(swap! app-state update :todos conj todo))
(defn update-todo! [id updates]
(swap! app-state update :todos
(fn [todos]
(mapv (fn [t]
(if (= (:id t) id)
(merge t updates)
t))
todos))))
(defn toggle-todo! [id]
(swap! app-state update :todos
(fn [todos]
(mapv (fn [t]
(if (= (:id t) id)
(update t :completed not)
t))
todos))))
(defn delete-todo! [id]
(swap! app-state update :todos
(fn [todos]
(filterv #(not= (:id %) id) todos))))
(defn set-filter! [f]
(swap! app-state assoc :filter f))
(defn get-filtered-todos [state]
(let [todos (:todos state)
filter-val (:filter state)]
(case filter-val
:all todos
:active (filterv #(not (:completed %)) todos)
:completed (filterv :completed todos))))
(defn get-counts [state]
(let [todos (:todos state)]
{:all (count todos)
:active (count (filterv #(not (:completed %)) todos))
:completed (count (filterv :completed todos))}))
@@ -0,0 +1,17 @@
(ns todo.widgets.empty-state
(:require
["package:flutter/material.dart" :as m]))
(defn empty-state-widget []
(m/Center
.child (m/Column
.mainAxisAlignment m/MainAxisAlignment.center
.children
[(m/Icon m/Icons.checklist .size 80
.color (.-shade400 m/Colors.grey))
(m/SizedBox .height 16)
(m/Text "No todos yet!\nTap + to add one"
.textAlign m/TextAlign.center
.style (m/TextStyle
.fontSize 18
.color (.-shade600 m/Colors.grey)))])))
@@ -0,0 +1,12 @@
(ns todo.widgets.priority-badge
(:require
["package:flutter/material.dart" :as m]
[todo.state :as state]))
(defn priority-badge [priority]
(m/Container
.width 12
.height 12
.decoration (m/BoxDecoration
.color (m/Color (get state/priority-colors priority 0xFF999999))
.shape m/BoxShape.circle)))
@@ -0,0 +1,87 @@
(ns todo.widgets.todo-form
(:require
["package:flutter/material.dart" :as m]
[cljd.flutter :as f]
[todo.state :as state]
[todo.db :as db]))
(defn show-todo-dialog [ctx todo]
(let [editing? (some? todo)
title-ctl (m/TextEditingController .text (or (:title todo) ""))
selected-category (atom (or (:category todo) "Personal"))
selected-priority (atom (or (:priority todo) :medium))]
(m/showDialog
.context ctx
.builder
(f/build
:context dialog-ctx
:watch [cat selected-category
pri selected-priority]
(m/AlertDialog
.title (m/Text (if editing? "Edit Todo" "Add New Todo"))
.content
(m/SingleChildScrollView
.child
(m/Column
.mainAxisSize m/MainAxisSize.min
.crossAxisAlignment m/CrossAxisAlignment.start
.children
[(m/TextField
.controller title-ctl
.decoration (m/InputDecoration
.labelText "What needs to be done?"
.border (m/OutlineInputBorder))
.autofocus true)
(m/SizedBox .height 16)
(m/Text "Category"
.style (m/TextStyle .fontWeight m/FontWeight.w600))
(m/SizedBox .height 8)
(m/Wrap
.spacing 8
.children
(into []
(map (fn [c]
(m/ChoiceChip
.label (m/Text c)
.selected (= c cat)
.onSelected (fn [_] (reset! selected-category c))))
state/categories)))
(m/SizedBox .height 16)
(m/Text "Priority"
.style (m/TextStyle .fontWeight m/FontWeight.w600))
(m/SizedBox .height 8)
(m/SegmentedButton
.segments
(into []
(map (fn [p]
(m/ButtonSegment
.value p
.label (m/Text (name p))))
state/priorities))
.selected #{pri}
.onSelectionChanged
(fn [selection]
(reset! selected-priority (first selection))))]))
.actions
[(m/TextButton
.onPressed (fn [] (.pop (m/Navigator.of dialog-ctx)))
.child (m/Text "Cancel"))
(m/FilledButton
.onPressed
(fn []
(let [title (.-text title-ctl)]
(when (seq (.trim title))
(if editing?
(do
(state/update-todo! (:id todo)
{:title title
:category @selected-category
:priority @selected-priority})
(db/update-todo-db! (:id todo) title
@selected-category @selected-priority))
(let [new-todo (await (db/insert-todo! title
@selected-category
@selected-priority))]
(state/add-todo! new-todo)))
(.pop (m/Navigator.of dialog-ctx)))))
.child (m/Text (if editing? "Save" "Add")))])))))
@@ -0,0 +1,43 @@
(ns todo.widgets.todo-item
(:require
["package:flutter/material.dart" :as m]
[cljd.flutter :as f]
[todo.state :as state]
[todo.db :as db]
[todo.widgets.priority-badge :as priority-badge]
[todo.widgets.todo-form :as todo-form]))
(defn todo-item-widget [todo]
(let [{:keys [id title completed category priority]} todo]
(f/widget
:context ctx
:get {{color-scheme .-colorScheme} m/Theme}
(m/Card
.margin (m/EdgeInsets.symmetric .horizontal 16 .vertical 4)
.child
(m/ListTile
.leading
(m/Checkbox
.value completed
.onChanged (fn [_]
(state/toggle-todo! id)
(db/toggle-todo-db! id (not completed))))
.title
(m/Text title
.style (m/TextStyle
.decoration (if completed
m/TextDecoration.lineThrough
m/TextDecoration.none)
.color (when completed
(.-onSurfaceVariant ^m/ColorScheme color-scheme))))
.subtitle
(m/Row
.children
[(m/Chip
.label (m/Text category
.style (m/TextStyle .fontSize 12))
.visualDensity m/VisualDensity.compact
.padding m/EdgeInsets.zero)
(m/SizedBox .width 8)
(priority-badge/priority-badge priority)])
.onTap (fn [] (todo-form/show-todo-dialog ctx todo)))))))
@@ -0,0 +1,32 @@
(ns todo.widgets.todo-list
(:require
["package:flutter/material.dart" :as m]
[cljd.flutter :as f]
[todo.state :as state]
[todo.db :as db]
[todo.widgets.todo-item :as todo-item]
[todo.widgets.empty-state :as empty-state]))
(defn todo-list-widget [todos]
(if (empty? todos)
(empty-state/empty-state-widget)
(m/ListView.builder
.padding (m/EdgeInsets.only .bottom 80)
.itemCount (count todos)
.itemBuilder
(f/build [idx]
(let [todo (nth todos idx)]
(m/Dismissible
.key (m/ValueKey (:id todo))
.direction m/DismissDirection.endToStart
.background
(m/Container
.alignment m/Alignment.centerRight
.padding (m/EdgeInsets.only .right 20)
.color m/Colors.red
.child (m/Icon m/Icons.delete .color m/Colors.white))
.onDismissed (fn [_]
(let [id (:id todo)]
(state/delete-todo! id)
(db/delete-todo-db! id)))
.child (todo-item/todo-item-widget todo)))))))