20251205 原案1
改善点
・現在は「できたかできなかったか」の2択だが、起床時刻のように時刻を入力したい場合がある
途中確認
npx expo start でローカルサーバーを立ち上げる
QRコードをスマホのexpo goアプリで読み取る
expo goはLAN接続なのでスマホとPCを同じWi-Fiにしないと、expo goの画面が「Something went wrong」という青いページになる
エラーが発生した場合、console.log はVSCodeのターミナルに表示される
expo goではなくブラウザで開くとエラーがどこにも表示されないため、バグがどこにあるのかわからない
プロジェクトの作成
npx create-expo@latest habitapp
-----------
※ npx create-expo-app [任意のアプリ名]では最新版バージョンでプロジェクトを作成できないinstall
npx expo install @react-native-async-storage/async-storageディレクトリ構成
habitapp
|---.vscode
|---app
| |---_layout.tsx
| |---index.tsx
| |---add-habit.tsx
| |---habit-history.tsx
| |---puzzle.tsx
|---assets
|---constants
|---hooks
|---node_modules
|---scripts
|---.gitignore
|---app.json
|---eslint.config.js
|---package-lock.json
|---package.json
|---tsconfig.json※_layout.tsxはページのルーティング設定
※appフォルダ直下の(tabs)フォルダは削除してOK
※ファイル名がそのままURLになるので、ファイル名はケバブケース推奨(aaa-bb.tsx)
AsyncStorage
[
{
"id": 1,
"title": "運動",
"createdAt": "2025-11-17",
"history": ["2025-11-17", "2025-11-18", "2025-11-19"]
}
]app/_layout.tsx
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
// スマホ下部のホームバーとアプリのボトムナビゲーションが重ならないようにするために必要
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
export default function Layout() {
return (
<SafeAreaProvider>
<SafeAreaView style={{ flex: 1 }}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#007AFF",
tabBarStyle: {
paddingBottom: 8,
paddingTop: 8,
},
}}
>
{/* ホーム */}
<Tabs.Screen
name="index"
options={{
title: "ホーム",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
{/* パズル */}
<Tabs.Screen
name="puzzle"
options={{
title: "パズル",
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid" size={size} color={color} />
),
}}
/>
</Tabs>
</SafeAreaView>
</SafeAreaProvider>
);
}app/index.tsx
// AsyncStorageはスマホ内のストレージであり、アプリを閉じてもデータが消えない
import FontAwesome6 from '@expo/vector-icons/FontAwesome6';
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback, useState } from "react";
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native";
type Habit = {
id: number,
title: string,
createdAt: string,
history: string[],
point: number,
isSelected: boolean,
};
export default function HomeScreen() {
// useState<Habit[]>([]) はTypeScriptによる型指定
// Habit[] ... Habitが複数入った配列という意味
// useState([])なので初期値は空
const [habits, setHabits] = useState<Habit[]>([]);
const router = useRouter();
const today = new Date().toISOString().split("T")[0];
// ストレージからHabitsを読み込む
const loadHabits = async () => {
const data = await AsyncStorage.getItem("habits");
const savedHabits = data ? JSON.parse(data) : [];
setHabits(savedHabits);
};
// useEffectは「画面に戻ってきたときに実行されない」のでuseFocusEffectを使う
// useCallbackは関数をメモ化するためのフックで、コンポーネントが再レンダリングされても、関数の参照を買えずに保持できる
// 同じ関数を何度も使う時にパフォーマンス最適化できる
useFocusEffect(
useCallback(() => {
loadHabits();
console.log("this screen is loaded!")
}, [])
);
// ==============================
// チェックボタンを押したときの処理
// ==============================
const toggleHabit = async (habit: Habit) => {
const updatedHabits = habits.map((h) => {
if (h.id === habit.id) {
// 今日やったか確認
const doneToday = h.history.includes(today);
let newHistory;
let newPoint = h.point;
// チェックを外したら-1ポイント
if (doneToday) {
newHistory = h.history.filter((d) => d !== today);
newPoint = newPoint - 1;
// 今日達成したら++1ポイント
} else {
newHistory = [...h.history, today];
newPoint = newPoint + 1;
}
return { ...h, history: newHistory, point: newPoint };
}
return h;
});
// habits変数を変更
setHabits(updatedHabits);
// ストレージに変更を保存
await AsyncStorage.setItem("habits", JSON.stringify(updatedHabits));
}
// =========
// 習慣の削除
// =========
const deleteHabit = async (habitID: number) => {
const updatedHabits = habits.filter(h => h.id !== habitID);
setHabits(updatedHabits);
await AsyncStorage.setItem('habits', JSON.stringify(updatedHabits));
};
// ==========
// 習慣の描画
// ==========
// <FlatList renderItem={renderItem}> に const renderItem = ({item} => {})が代入される
// {item}: {item:Habit} はTypeScriptでitemをHabit型に指定
// Habit型とはtype Habit = {}で決めた型のこと
const renderItem = ({ item }: { item: Habit }) => {
const doneToday = item.history.includes(today);
return (
<View style={styles.vwHabitItem}>
<View style={styles.vwHabitTitleWrap}>
<Text style={styles.txtHabitTitle}>{item.title}</Text>
{/* 削除ボタン */}
<TouchableOpacity
style={styles.toTrashBox}
onPress={() => deleteHabit(item.id)}>
<FontAwesome6 name="trash" size={16} color="tomato" />
</TouchableOpacity>
</View>
<Text style={styles.txtAddDate}>追加日 : {new Date(item.createdAt).toLocaleDateString()}</Text>
{/* チェックボタン */}
<TouchableOpacity
style={[doneToday ? styles.toDoneToday : styles.toNotDoneToday]}
onPress={() => toggleHabit(item)}>
<Text style={doneToday ? styles.txtDoneToday: styles.txtNotDoneToday}>{doneToday ? "できた!" : "達成したらクリック"}</Text>
</TouchableOpacity>
{/* 履歴画面へのリンク */}
<TouchableOpacity
onPress={() => router.push({
pathname: '/habit-history',
params: { id: item.id.toString() }
})}>
<Text style={{ color: "blue", marginTop: 5 }}>履歴を見る</Text>
</TouchableOpacity>
<Text>現在のポイント:{item.point}</Text>
</View>
);
};
return (
<View style={styles.vwContainer}>
<View style={styles.vwHeaderWrap}>
<Text style={styles.txtHabitList}>Habit List</Text>
<TouchableOpacity style={styles.toAddHabit} onPress={() => router.push('/add-habit')}>
<Text style={styles.btnAddHabit}>+ Add New Habit</Text>
</TouchableOpacity>
</View>
<FlatList
data={habits}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
ListEmptyComponent={<Text>no habit yet</Text>}
/>
<TouchableOpacity
style={styles.toPuzzlePage}
onPress={() => router.push('/habit-history')}
>
<Text style={styles.txtPuzzlePage}>履歴画面</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.toPuzzlePage}
onPress={() => router.push('/puzzle')}
>
<Text style={styles.txtPuzzlePage}>パズル画面</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
// View
vwContainer: { flex: 1, padding: 20, backgroundColor: "#fff" },
vwHeaderWrap: { display: "flex", flexDirection: "row", alignItems: "center", marginBottom: 15, },
vwHabitTitleWrap: { display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "space-between"},
vwHabitItem: { padding: 15, borderWidth: 1, borderColor: "#ccc", borderRadius: 8, marginBottom: 10 },
// Text
txtHabitList: { color: "#777", fontSize: 18, fontWeight: 400, padding: 10 },
txtHabitTitle: { fontSize: 18 },
txtAddDate: { marginTop: 5, marginBottom: 10, color: "#999", fontSize: 14 },
txtDoneToday: { color: "#fff", fontWeight: "bold" },
txtNotDoneToday: { color: "tomato" },
txtPuzzlePage: { color: "#4682b4" },
// TouchableOpacity
toAddHabit: { width: 200, backgroundColor: "tomato", borderRadius: 5, alignItems: "center" },
toTrashBox: { },
toNotDoneToday: { display: "flex", alignItems: "center", justifyContent: "center", width: "100%", height: 40, borderRadius: 5, borderWidth: 1, borderColor: "tomato" },
toDoneToday: { display: "flex", alignItems: "center", justifyContent: "center", width: "100%", height: 40, borderRadius: 5, borderWidth: 1, borderColor: "tomato", backgroundColor: "tomato" },
toPuzzlePage: {},
// Button
btnAddHabit: { color: "#fff", fontSize: 16, padding: 10, },
header: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
});app/habit-history.tsx
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
type Habit = {
id: number;
title: string;
createdAt: string;
history: string[];
point: number,
};
export default function HabitHistory() {
const [habits, setHabits] = useState<Habit[]>([]);
//
useEffect(() => {
loadHabits();
}, []);
// ====================
// ストレージの読み込み
// ====================
const loadHabits = async () => {
const data = await AsyncStorage.getItem('habits');
const habits: Habit[] = data ? JSON.parse(data) : [];
setHabits(habits);
};
// =============
// 習慣が0個の場合
// =============
if (habits.length === 0) return <Text>NO HISTORY YET ...</Text>;
// =======================================================
// 今日の年月から「当月の日数」を求めてカレンダー用の配列を用意
// =======================================================
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const daysInMonth = new Date(year, month, 0).getDate();
const daysArray = Array.from({ length: daysInMonth }, (_, i) => i + 1);
return (
<ScrollView horizontal>
<View style={styles.table}>
{/* ヘッダー行(日付) */}
<View style={[styles.row, styles.headerRow]}>
<View style={[styles.cell, styles.habitNameCell]}>
<Text style={styles.headerText}>習慣名</Text>
</View>
{daysArray.map((day) => {
const dateObj = new Date(`${year}-${month}-${day}`);
const weekday = ["日", "月", "火", "水", "木", "金", "土"][dateObj.getDay()];
return (
<View key={day} style={[styles.cell, styles.dayCell]}>
<Text style={styles.headerText}>{day}</Text>
<Text style={styles.weekdayText}>{weekday}</Text>
</View>
);
})}
</View>
{/* 各習慣の行 */}
{habits.map((habit) => (
<View key={habit.id} style={styles.row}>
{/* 習慣名セル */}
<View style={[styles.cell, styles.habitNameCell]}>
<Text style={styles.habitName}>{habit.title}</Text>
</View>
{/* 日付セル */}
{daysArray.map((day) => {
const dayStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const done = habit.history.includes(dayStr);
return (
<View key={day} style={[styles.cell, styles.dayCell, done ? styles.dayDone : styles.dayNotDone]} />
);
})}
</View>
))}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
table: {
flexDirection: "column",
},
row: {
flexDirection: "row",
alignItems: "center",
},
headerRow: {
backgroundColor: "#f7f7f7",
},
cell: {
borderWidth: 1,
borderColor: "#ccc",
justifyContent: "center",
alignItems: "center",
},
habitNameCell: {
width: 120,
padding: 5,
},
dayCell: {
width: 35,
height: 35,
},
habitName: {
fontSize: 16,
fontWeight: "bold",
},
headerText: {
fontSize: 12,
fontWeight: "bold",
},
dayDone: {
backgroundColor: "tomato",
},
dayNotDone: {
backgroundColor: "#eee",
},
weekdayText: {
fontSize: 10,
color: "#666",
},
});app/add-habit.tsx
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Alert, Button, StyleSheet, Text, TextInput, View } from 'react-native';
export default function AddHabit() {
const [title, setTitle] = useState('');
const router = useRouter();
const saveHabit = async () => {
if (!title.trim()) {
Alert.alert('ERROR', 'put in habit name');
return;
}
// 既存の習慣をストレージから取り出す
const data = await AsyncStorage.getItem('habits');
const habits = data ? JSON.parse(data) : [];
// 新しい習慣をストレージに追加する
const newHabit = {
id: Date.now(),
title,
createdAt: new Date().toISOString(),
history: [],
point: 0,
};
habits.push(newHabit);
await AsyncStorage.setItem('habits', JSON.stringify(habits));
// ホームに戻る
router.replace('/');
};
return (
<View style={styles.container}>
<Text style={styles.label}>Add New Habit</Text>
<TextInput
style={styles.input}
placeholder="ex) Brush Tooth"
value={title}
onChangeText={setTitle}
/>
<View>
<Button title="SAVE" onPress={saveHabit} />
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#fff' },
label: { fontSize: 22, marginBottom: 10 },
input: { borderWidth: 1, borderColor: "#ccc", padding: 10, borderRadius: 5, marginBottom: 20 },
});