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:
@@ -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]))))
|
||||
@@ -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)))
|
||||
@@ -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)))))))
|
||||
Reference in New Issue
Block a user