Skip to content

Commit eda5b79

Browse files
streamcode9claude
andauthored
Add iOS PWA todo list app (#47)
Create a simple todo list Progressive Web App with: - iOS-optimized styling and safe area support - Dark mode support - Service worker for offline functionality - Local storage persistence - SVG icon for app icon Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18f1439 commit eda5b79

6 files changed

Lines changed: 627 additions & 0 deletions

File tree

apps/todo/app.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// DOM Elements
2+
const addForm = document.getElementById('addForm');
3+
const todoInput = document.getElementById('todoInput');
4+
const todoList = document.getElementById('todoList');
5+
const emptyState = document.getElementById('emptyState');
6+
const footer = document.getElementById('footer');
7+
const taskCount = document.getElementById('taskCount');
8+
const clearCompleted = document.getElementById('clearCompleted');
9+
10+
// State
11+
let todos = [];
12+
13+
// Initialize
14+
document.addEventListener('DOMContentLoaded', () => {
15+
loadTodos();
16+
render();
17+
registerServiceWorker();
18+
});
19+
20+
// Register Service Worker
21+
function registerServiceWorker() {
22+
if ('serviceWorker' in navigator) {
23+
navigator.serviceWorker.register('./service-worker.js')
24+
.then((registration) => {
25+
console.log('Service Worker registered:', registration.scope);
26+
})
27+
.catch((error) => {
28+
console.log('Service Worker registration failed:', error);
29+
});
30+
}
31+
}
32+
33+
// Load todos from localStorage
34+
function loadTodos() {
35+
const stored = localStorage.getItem('todos');
36+
if (stored) {
37+
try {
38+
todos = JSON.parse(stored);
39+
} catch (e) {
40+
todos = [];
41+
}
42+
}
43+
}
44+
45+
// Save todos to localStorage
46+
function saveTodos() {
47+
localStorage.setItem('todos', JSON.stringify(todos));
48+
}
49+
50+
// Generate unique ID
51+
function generateId() {
52+
return Date.now().toString(36) + Math.random().toString(36).substr(2);
53+
}
54+
55+
// Add new todo
56+
function addTodo(text) {
57+
const todo = {
58+
id: generateId(),
59+
text: text.trim(),
60+
completed: false,
61+
createdAt: Date.now()
62+
};
63+
todos.unshift(todo);
64+
saveTodos();
65+
render();
66+
}
67+
68+
// Toggle todo completion
69+
function toggleTodo(id) {
70+
const todo = todos.find(t => t.id === id);
71+
if (todo) {
72+
todo.completed = !todo.completed;
73+
saveTodos();
74+
render();
75+
}
76+
}
77+
78+
// Delete todo
79+
function deleteTodo(id) {
80+
todos = todos.filter(t => t.id !== id);
81+
saveTodos();
82+
render();
83+
}
84+
85+
// Clear completed todos
86+
function clearCompletedTodos() {
87+
todos = todos.filter(t => !t.completed);
88+
saveTodos();
89+
render();
90+
}
91+
92+
// Create todo element
93+
function createTodoElement(todo) {
94+
const li = document.createElement('li');
95+
li.className = 'todo-item';
96+
li.dataset.id = todo.id;
97+
98+
const checkbox = document.createElement('div');
99+
checkbox.className = `todo-checkbox ${todo.completed ? 'checked' : ''}`;
100+
checkbox.addEventListener('click', () => toggleTodo(todo.id));
101+
102+
const text = document.createElement('span');
103+
text.className = `todo-text ${todo.completed ? 'completed' : ''}`;
104+
text.textContent = todo.text;
105+
106+
const deleteBtn = document.createElement('button');
107+
deleteBtn.className = 'delete-btn';
108+
deleteBtn.innerHTML = '×';
109+
deleteBtn.addEventListener('click', () => deleteTodo(todo.id));
110+
111+
li.appendChild(checkbox);
112+
li.appendChild(text);
113+
li.appendChild(deleteBtn);
114+
115+
return li;
116+
}
117+
118+
// Render the todo list
119+
function render() {
120+
// Clear current list
121+
todoList.innerHTML = '';
122+
123+
// Render todos
124+
todos.forEach(todo => {
125+
todoList.appendChild(createTodoElement(todo));
126+
});
127+
128+
// Update empty state
129+
if (todos.length === 0) {
130+
emptyState.classList.add('visible');
131+
footer.classList.remove('visible');
132+
} else {
133+
emptyState.classList.remove('visible');
134+
footer.classList.add('visible');
135+
}
136+
137+
// Update task count
138+
const activeTasks = todos.filter(t => !t.completed).length;
139+
const totalTasks = todos.length;
140+
taskCount.textContent = `${activeTasks} of ${totalTasks} task${totalTasks !== 1 ? 's' : ''} remaining`;
141+
142+
// Update clear button state
143+
const hasCompleted = todos.some(t => t.completed);
144+
clearCompleted.disabled = !hasCompleted;
145+
}
146+
147+
// Event listeners
148+
addForm.addEventListener('submit', (e) => {
149+
e.preventDefault();
150+
const text = todoInput.value.trim();
151+
if (text) {
152+
addTodo(text);
153+
todoInput.value = '';
154+
todoInput.blur(); // Hide keyboard on mobile
155+
}
156+
});
157+
158+
clearCompleted.addEventListener('click', () => {
159+
if (confirm('Clear all completed tasks?')) {
160+
clearCompletedTodos();
161+
}
162+
});
163+
164+
// Prevent zoom on input focus (iOS)
165+
todoInput.addEventListener('focus', () => {
166+
document.body.style.zoom = '1';
167+
});
168+
169+
// Handle visibility change to save data
170+
document.addEventListener('visibilitychange', () => {
171+
if (document.visibilityState === 'hidden') {
172+
saveTodos();
173+
}
174+
});

apps/todo/icons/icon.svg

Lines changed: 4 additions & 0 deletions
Loading

apps/todo/index.html

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
6+
<meta name="apple-mobile-web-app-capable" content="yes">
7+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8+
<meta name="apple-mobile-web-app-title" content="Todo">
9+
<meta name="theme-color" content="#007AFF">
10+
<meta name="description" content="Simple Todo List PWA for iOS">
11+
12+
<title>Todo List</title>
13+
14+
<!-- PWA Manifest -->
15+
<link rel="manifest" href="manifest.json">
16+
17+
<!-- iOS Icon -->
18+
<link rel="apple-touch-icon" href="icons/icon.svg">
19+
20+
<!-- Favicon -->
21+
<link rel="icon" type="image/svg+xml" href="icons/icon.svg">
22+
23+
<!-- Styles -->
24+
<link rel="stylesheet" href="style.css">
25+
</head>
26+
<body>
27+
<div class="app">
28+
<header class="header">
29+
<h1>Todo List</h1>
30+
</header>
31+
32+
<main class="main">
33+
<form class="add-form" id="addForm">
34+
<input
35+
type="text"
36+
id="todoInput"
37+
class="todo-input"
38+
placeholder="Add a new task..."
39+
autocomplete="off"
40+
autocorrect="on"
41+
>
42+
<button type="submit" class="add-btn">+</button>
43+
</form>
44+
45+
<ul class="todo-list" id="todoList">
46+
<!-- Todo items will be inserted here -->
47+
</ul>
48+
49+
<div class="empty-state" id="emptyState">
50+
<div class="empty-icon"></div>
51+
<p>No tasks yet</p>
52+
<p class="empty-hint">Add a task above to get started</p>
53+
</div>
54+
</main>
55+
56+
<footer class="footer" id="footer">
57+
<span id="taskCount">0 tasks</span>
58+
<button class="clear-btn" id="clearCompleted">Clear completed</button>
59+
</footer>
60+
</div>
61+
62+
<script src="app.js"></script>
63+
</body>
64+
</html>

apps/todo/manifest.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "Todo List",
3+
"short_name": "Todo",
4+
"description": "Simple Todo List PWA for iOS",
5+
"start_url": "./",
6+
"display": "standalone",
7+
"background_color": "#ffffff",
8+
"theme_color": "#007AFF",
9+
"orientation": "portrait-primary",
10+
"icons": [
11+
{
12+
"src": "icons/icon.svg",
13+
"sizes": "any",
14+
"type": "image/svg+xml",
15+
"purpose": "any"
16+
},
17+
{
18+
"src": "icons/icon.svg",
19+
"sizes": "512x512",
20+
"type": "image/svg+xml",
21+
"purpose": "maskable"
22+
}
23+
]
24+
}

apps/todo/service-worker.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
const CACHE_NAME = 'todo-pwa-v1';
2+
const urlsToCache = [
3+
'./',
4+
'./index.html',
5+
'./style.css',
6+
'./app.js',
7+
'./manifest.json',
8+
'./icons/icon.svg'
9+
];
10+
11+
// Install event - cache files
12+
self.addEventListener('install', (event) => {
13+
event.waitUntil(
14+
caches.open(CACHE_NAME)
15+
.then((cache) => {
16+
console.log('Opened cache');
17+
return cache.addAll(urlsToCache);
18+
})
19+
.catch((error) => {
20+
console.log('Cache install failed:', error);
21+
})
22+
);
23+
self.skipWaiting();
24+
});
25+
26+
// Activate event - clean up old caches
27+
self.addEventListener('activate', (event) => {
28+
event.waitUntil(
29+
caches.keys().then((cacheNames) => {
30+
return Promise.all(
31+
cacheNames.map((cacheName) => {
32+
if (cacheName !== CACHE_NAME) {
33+
console.log('Deleting old cache:', cacheName);
34+
return caches.delete(cacheName);
35+
}
36+
})
37+
);
38+
})
39+
);
40+
self.clients.claim();
41+
});
42+
43+
// Fetch event - serve from cache, fallback to network
44+
self.addEventListener('fetch', (event) => {
45+
event.respondWith(
46+
caches.match(event.request)
47+
.then((response) => {
48+
// Return cached version or fetch from network
49+
if (response) {
50+
return response;
51+
}
52+
return fetch(event.request).then((response) => {
53+
// Don't cache non-successful responses
54+
if (!response || response.status !== 200 || response.type !== 'basic') {
55+
return response;
56+
}
57+
// Clone the response
58+
const responseToCache = response.clone();
59+
caches.open(CACHE_NAME)
60+
.then((cache) => {
61+
cache.put(event.request, responseToCache);
62+
});
63+
return response;
64+
});
65+
})
66+
.catch(() => {
67+
// Return offline fallback if available
68+
return caches.match('./index.html');
69+
})
70+
);
71+
});

0 commit comments

Comments
 (0)