Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
- **2種類の手帳**
- **プロフィール帳式** — 最初からかわいい飾り枠つき。工程フローや自由帳ページを作れる
- **ノート式** — 白紙のキャンバスに、テキスト・写真・シールを自由に配置してコラージュ
- **工程フロー** — 工程をチェックリストで並べる。完了するとシールが弾ける演出。**if分岐**(はい/いいえ)で条件分けもでき、分岐の中にさらにif分岐を**入れ子**にして深い条件分けも作れる
- **フリーコラージュ** — テキストカード・写真・シールをドラッグ/拡大/回転で自由配置。カード同士を**つなぎ線**で結べる
- **シール** — 星・花・ハート・リボン・きらめきの5種類。好きな大きさ・角度で貼れる
- **工程フロー** — 工程をチェックリストで並べる。完了するとシールが弾ける演出と、上部の進み具合(`3/7`)表示。**if分岐**(はい/いいえ)で条件分けもでき、分岐の中にさらにif分岐を**入れ子**にして深い条件分けも作れる。分岐の中の工程も▲▼で並べ替えられる
- **フリーコラージュ** — テキストカード・写真・シールをドラッグ/拡大/回転で自由配置。カード同士を**つなぎ線**で結べる(線そのものも動かす/伸ばす/回すことができ、`↺`で自動配置に戻せる)
- **コピー** — 手帳まるごと(📄)と、ページ単位(タブの📄)の複製
- **シール** — 星・花・ハート・リボン・きらめき・くも・三日月・王冠・ちょうちょ・にくきゅう・宝石の11種類。好きな大きさ・角度で貼れる
- **飾り枠** — シンプル/オーロラ滲み/星屑ちらし/リボン留め
- **用紙** — ノート式は横罫・方眼・ドット・無地+9色の用紙色
- **きらめき** — 画面いっぱいに星がまたたく環境演出のオン/オフ
- **画像で保存** — 盤面をPNGにして共有・保存(📸)
- **バックアップ** — JSON書き出し/読み込み。**Web版オリジナルのバックアップJSONもそのまま読み込めます**(写真も含めて相互互換)
- **元に戻す** — 直前の操作をひとつ戻す(最大15手)
- **バックアップ** — JSON書き出し/読み込み(写真も丸ごと1ファイルに入ります)
- **元に戻す** — 直前の操作をひとつ戻す(最大15手)。手帳・ページの削除や、バックアップの読み込みも戻せます

端末の「視差効果を減らす」設定がオンのときは、きらめきと紙吹雪のアニメーションを止めます。

## APKの入手

Expand All @@ -29,4 +32,35 @@
- 作った手帳・ページ・シール・写真は、**すべて端末内だけ**に保存されます(外部のサーバーには一切送られません)
- 写真もJSON内にそのまま埋め込んで保存するので、バックアップ1ファイルで丸ごと持ち運べます
- アプリをアンインストールするとデータは消えます。**大事な記録は下部ツールバーの「💾 書き出し」から、ときどきJSONファイルに書き出しておくことを強くおすすめします**
- 書き出したJSONは「📂 読み込み」から復元できます(今のデータは上書きされるので注意)
- 書き出したJSONは「⋯ → 📂 バックアップを読み込む」から復元できます(今のデータは置きかわりますが、直後なら「↩️ 元に戻す」で戻せます)

### バックアップの形式について(0.3.0で変更)

0.3.0 から、書き出すJSONは形式名とバージョンを持つ「封筒」に包まれた独自形式になりました。

```json
{
"format": "kawaii-deconote-android",
"formatVersion": 1,
"appVersion": "0.3.0",
"exportedAt": "2026-07-25T00:00:00.000Z",
"state": { "activeNotebookId": null, "notebooks": [] }
}
```

これにともない、**Web版オリジナルが書き出したJSONの読み込み対応は終了しました**。Android版は独自の保存形式として進めます。0.2.x以前のAndroid版が書き出したJSONも同じ理由で読み込めないので、古いバックアップが手元にある場合は0.2.xで復元してから0.3.0で書き出し直してください。

## 開発

```sh
npm ci
npm run typecheck # TypeScript
npm run lint # oxlint
npm run test # vitest(純粋ロジックはカバレッジ100%を維持)
npm run format # prettier
npm run android # 実機・エミュレータで起動
```

- `src/lib` … 状態を持たない純粋ロジック(モデル・正規化・複製・色・バックアップ形式)。ここだけがテスト対象です
- `src/state` … zustand ストア(保存・巻き戻し・UIの一時状態)
- `src/components` … 画面パーツ。色・字・余白は `src/lib/theme.ts` のトークンから取ります
4 changes: 2 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"expo": {
"name": "かわいくデコれる手帳",
"slug": "kawaii-deconote",
"version": "0.2.1",
"version": "0.3.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "kawaiideconote",
Expand All @@ -15,7 +15,7 @@
},
"android": {
"package": "io.github.pppzet.kawaiideconote",
"versionCode": 3,
"versionCode": 4,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#F3E9F7"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kawaii-deconote",
"version": "0.2.1",
"version": "0.3.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
Expand Down
9 changes: 7 additions & 2 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { recoverPendingPhotoAsDataUrl } from "@/lib/files";
import { colors } from "@/lib/theme";
import { selectCurrentNotebook, selectCurrentPage, useNotebooks } from "@/state/notebooks";

const AUTOSAVE_MS = 8000;

void SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
Expand All @@ -35,6 +37,7 @@ export default function RootLayout() {
setInitialized(true);
}, []);

// アプリが前面から外れる瞬間と、一定時間ごとに保存する。
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state !== "active") useNotebooks.getState().flushPending();
Expand All @@ -43,10 +46,11 @@ export default function RootLayout() {
}, []);

useEffect(() => {
const id = setInterval(() => useNotebooks.getState().flushPending(), 8000);
const id = setInterval(() => useNotebooks.getState().flushPending(), AUTOSAVE_MS);
return () => clearInterval(id);
}, []);

// 写真を選んでいる途中でAndroidにアプリを落とされた場合、戻ってきたときに拾い直す。
useEffect(() => {
const recover = async () => {
const before = useNotebooks.getState();
Expand All @@ -73,7 +77,8 @@ export default function RootLayout() {

return (
<GestureHandlerRootView style={styles.root}>
<ReducedMotionConfig mode={ReduceMotion.Never} />
{/* 端末の「視差効果を減らす」設定をそのまま尊重する */}
<ReducedMotionConfig mode={ReduceMotion.System} />
<SafeAreaProvider>
<View style={styles.root}>
<Background chic={chic} />
Expand Down
137 changes: 99 additions & 38 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,90 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CoverGrid } from "@/components/cover/CoverGrid";
import { CreatePanel } from "@/components/cover/CreatePanel";
import { PromptModal } from "@/components/ui/PromptModal";
import { tapFeedback } from "@/lib/haptics";
import { notebookDisplayName } from "@/lib/model";
import { colors, fonts } from "@/lib/theme";
import { colors, radii, space, text } from "@/lib/theme";
import { MAX_NOTEBOOK_NAME } from "@/lib/types";
import type { NotebookType } from "@/lib/types";
import { useImportBackup } from "@/state/backup";
import { useNotebooks } from "@/state/notebooks";
import { useUi } from "@/state/ui";

export default function CoverScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const notebooks = useNotebooks((s) => s.doc.notebooks);
const storageOk = useNotebooks((s) => s.storageOk);
const storageError = useNotebooks((s) => s.storageError);
const canUndo = useNotebooks((s) => s.canUndo);
const createNotebook = useNotebooks((s) => s.createNotebook);
const duplicateNotebook = useNotebooks((s) => s.duplicateNotebook);
const deleteNotebook = useNotebooks((s) => s.deleteNotebook);
const renameNotebook = useNotebooks((s) => s.renameNotebook);
const recheckStorage = useNotebooks((s) => s.recheckStorage);
const showToast = useUi((s) => s.showToast);

const [creating, setCreating] = useState(false);
const [renameId, setRenameId] = useState<string | null>(null);

const open = (id: string) => router.push({ pathname: "/notebook/[id]", params: { id } });
// 画面を出す前に「開いている手帳」を確定させる。編集画面の初回描画で
// 一瞬「手帳が見つかりませんでした」が出るのを防ぐため。
const open = (id: string) => {
useNotebooks.getState().openNotebook(id);
router.push({ pathname: "/notebook/[id]", params: { id } });
};

const onCreate = (type: NotebookType, name: string, color: string) => {
const id = createNotebook(type, name, color);
setCreating(false);
open(id);
};

const onDuplicate = (id: string) => {
if (duplicateNotebook(id) === null) return;
tapFeedback();
showToast("手帳をコピーしたよ📔✨");
};

const onDelete = (id: string) => {
Alert.alert("手帳を削除", "この手帳を削除します。中のページも全部消えます。よろしいですか?", [
{ text: "やめる", style: "cancel" },
{ text: "削除", style: "destructive", onPress: () => deleteNotebook(id) },
]);
const nb = notebooks.find((n) => n.id === id);
if (!nb) return;
Alert.alert(
"手帳を削除",
`「${notebookDisplayName(nb)}」と中の${nb.pages.length}ページを削除します。消したあとでも「元に戻す」で戻せます。`,
[
{ text: "やめる", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
deleteNotebook(id);
showToast("手帳を削除しました。元に戻せます🗑️");
},
},
],
);
};

const onUndo = () => {
const ok = useNotebooks.getState().undo();
showToast(ok ? "↩️ ひとつ前に戻しました" : "⚠️ まだ保存中で戻せません…もう一度どうぞ");
};

const onImport = useImportBackup();

const renameTarget = notebooks.find((n) => n.id === renameId);
const subtitle = notebooks.length === 0 ? "まずは1冊、表紙から選んでつくろう" : `${notebooks.length}冊の手帳`;

return (
<>
<ScrollView
contentContainerStyle={[styles.content, { paddingTop: insets.top + 22 }]}
contentContainerStyle={[styles.content, { paddingTop: insets.top + space.xl }]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>かわいくデコれる手帳 🌸✨</Text>
<Text style={styles.subtitle}>お気に入りの手帳を選んでね</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>

{!storageOk ? (
Expand All @@ -62,20 +98,34 @@ export default function CoverScreen() {
</Text>
{storageError ? <Text style={styles.warningDetail}>{storageError}</Text> : null}
<View style={styles.warnActions}>
<Pressable style={styles.recheck} onPress={recheckStorage}>
<Pressable style={styles.recheck} onPress={recheckStorage} accessibilityRole="button">
<Text style={styles.recheckText}>🔧 もう一度確認する</Text>
</Pressable>
<Pressable style={styles.recheck} onPress={onImport}>
<Pressable style={styles.recheck} onPress={onImport} accessibilityRole="button">
<Text style={styles.recheckText}>📂 バックアップから復元</Text>
</Pressable>
</View>
</View>
) : null}

{/* 一覧側でも削除・コピーを取り消せるようにしておく(編集画面のツールバーは
ここからは届かないため) */}
{canUndo ? (
<Pressable
style={({ pressed }) => [styles.undo, pressed && styles.undoPressed]}
onPress={onUndo}
accessibilityRole="button"
accessibilityLabel="ひとつ前に戻す"
>
<Text style={styles.undoText}>↩️ さっきの操作を元に戻す</Text>
</Pressable>
) : null}

<CoverGrid
notebooks={notebooks}
onOpen={open}
onRename={(id) => setRenameId(id)}
onDuplicate={onDuplicate}
onDelete={onDelete}
onAdd={() => setCreating(true)}
/>
Expand All @@ -88,7 +138,7 @@ export default function CoverScreen() {
title="手帳の名前を入力してね📔"
initialValue={renameTarget ? notebookDisplayName(renameTarget) : ""}
placeholder="手帳のなまえ"
maxLength={30}
maxLength={MAX_NOTEBOOK_NAME}
onCancel={() => setRenameId(null)}
onSubmit={(name) => {
if (renameId) renameNotebook(renameId, name);
Expand All @@ -101,48 +151,43 @@ export default function CoverScreen() {

const styles = StyleSheet.create({
content: {
paddingHorizontal: 16,
paddingHorizontal: space.lg,
paddingBottom: 40,
maxWidth: 560,
width: "100%",
alignSelf: "center",
},
header: {
alignItems: "center",
marginBottom: 18,
marginBottom: space.lg,
},
title: {
fontFamily: fonts.display,
fontSize: 24,
...text.displayL,
color: colors.plum,
marginBottom: 4,
marginBottom: space.xs,
textAlign: "center",
},
subtitle: {
fontFamily: fonts.body,
fontSize: 12,
...text.label,
color: colors.ink,
opacity: 0.6,
},
warning: {
backgroundColor: "#fff3e0",
backgroundColor: colors.warnBg,
borderWidth: 1.5,
borderColor: "#f0b86e",
borderColor: colors.warnBorder,
borderRadius: 14,
padding: 12,
marginBottom: 14,
padding: space.md,
marginBottom: space.md,
},
warningText: {
fontFamily: fonts.body,
fontSize: 12,
color: "#7a4a12",
...text.label,
color: colors.warnInk,
textAlign: "center",
lineHeight: 18,
},
warningDetail: {
fontFamily: fonts.body,
fontSize: 10,
color: "#7a4a12",
...text.micro,
color: colors.warnInk,
opacity: 0.8,
marginTop: 6,
textAlign: "center",
Expand All @@ -151,19 +196,35 @@ const styles = StyleSheet.create({
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: 8,
marginTop: 8,
gap: space.sm,
marginTop: space.sm,
},
recheck: {
borderWidth: 1,
borderColor: "#d99a4a",
borderRadius: 10,
paddingVertical: 4,
paddingHorizontal: 12,
borderColor: "#D99A4A",
borderRadius: radii.small,
paddingVertical: 5,
paddingHorizontal: space.md,
},
recheckText: {
fontFamily: fonts.body,
fontSize: 11,
color: "#7a4a12",
...text.caption,
color: colors.warnInk,
},
undo: {
alignSelf: "center",
marginBottom: space.md,
paddingVertical: 7,
paddingHorizontal: space.lg,
borderRadius: radii.pill,
backgroundColor: colors.veilStrong,
borderWidth: 1.5,
borderColor: colors.hairline,
},
undoPressed: {
opacity: 0.75,
},
undoText: {
...text.caption,
color: colors.plum,
},
});
Loading
Loading