20251205 原案1

ReactNative

改善点

・現在は「できたかできなかったか」の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 },
});

BACK