diff --git a/src/app/(journal)/entry/[id].tsx b/src/app/(journal)/entry/[id].tsx
index 1c1a2ec..35fe800 100644
--- a/src/app/(journal)/entry/[id].tsx
+++ b/src/app/(journal)/entry/[id].tsx
@@ -49,6 +49,16 @@ export default function EntryDetailScreen() {
unstable_headerRightItems: editMode
? undefined
: () => [
+ {
+ type: "button",
+ icon: {
+ type: "sfSymbol",
+ name: entry?.bookmark ? "bookmark.fill" : "bookmark",
+ },
+ label: entry?.bookmark ? t("entry.unbookmark") : t("entry.bookmark"),
+ tintColor: entry?.bookmark ? PlatformColor("systemIndigo") : undefined,
+ onPress: handleBookmark,
+ },
{
type: "menu",
label: t("common.options"),
@@ -74,16 +84,7 @@ export default function EntryDetailScreen() {
],
},
},
- {
- type: "button",
- icon: {
- type: "sfSymbol",
- name: entry?.bookmark ? "bookmark.fill" : "bookmark",
- },
- label: entry?.bookmark ? t("entry.unbookmark") : t("entry.bookmark"),
- tintColor: entry?.bookmark ? PlatformColor("systemIndigo") : undefined,
- onPress: handleBookmark,
- },
+
{
type: "button",
label: t("common.edit"),
diff --git a/src/app/(journal)/index.tsx b/src/app/(journal)/index.tsx
index 1c294dd..2c5d4ac 100644
--- a/src/app/(journal)/index.tsx
+++ b/src/app/(journal)/index.tsx
@@ -51,6 +51,12 @@ export default function JournalScreen() {
title: t("tabs.journal"),
headerLargeTitleEnabled: true,
unstable_headerRightItems: () => [
+ {
+ type: "button",
+ label: t("journal.newJournal"),
+ icon: { type: "sfSymbol", name: "folder.badge.plus" },
+ onPress: () => router.push("/(journal)/create"),
+ },
...(activeJournal
? [
{
@@ -61,6 +67,7 @@ export default function JournalScreen() {
name: "ellipsis" as const,
},
menu: {
+ title: activeJournal.name,
items: [
{
type: "action" as const,
@@ -110,12 +117,6 @@ export default function JournalScreen() {
},
]
: []),
- {
- type: "button",
- label: t("journal.newJournal"),
- icon: { type: "sfSymbol", name: "folder.badge.plus" },
- onPress: () => router.push("/(journal)/create"),
- },
],
}}
/>
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index f6b8f9d..08b5c48 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -1,13 +1,21 @@
import "@/i18n";
import "@/polyfills";
-import React from "react";
+import React, { useEffect } from "react";
+
+import { downloadModel } from "@react-native-ai/llama";
import AppTabs from "@/components/app-tabs";
import { DrizzleProvider } from "@/components/drizzle-provider";
+import { AI_MODEL } from "@/constants/ai-models";
import { useAutoReflection } from "@/hooks/settings/use-auto-reflection";
function AppContent() {
useAutoReflection();
+
+ useEffect(() => {
+ downloadModel(AI_MODEL.gguf).catch((e) => console.warn("[model-download]", e));
+ }, []);
+
return ;
}
diff --git a/src/app/days/_layout.tsx b/src/app/days/_layout.tsx
index c2b3317..56267be 100644
--- a/src/app/days/_layout.tsx
+++ b/src/app/days/_layout.tsx
@@ -21,6 +21,7 @@ export default function DaysLayout() {
}}
>
+
startOfDay());
- const [showCalendar, setShowCalendar] = useState(false);
const router = useRouter();
- const glassWidth = useSharedValue(200);
+ const today = startOfDay();
+
+ const [selectedDate, setSelectedDate] = useState(() => today);
+ const [slotDates, setSlotDates] = useState<[Date, Date, Date]>(() => [
+ addDays(today, -1),
+ today,
+ addDays(today, 1),
+ ]);
+ const [showCalendar, setShowCalendar] = useState(false);
+ // Refs for animation callbacks (closures)
+ const slotDatesRef = useRef(slotDates);
+ slotDatesRef.current = slotDates;
+
+ // Shared values
+ const centerSlot = useSharedValue(1);
const translateX = useSharedValue(0);
+ const glassWidth = useSharedValue(200);
+ const canGoNextSV = useSharedValue(addDays(selectedDate, 1) <= today);
- const prevDate = addDays(selectedDate, -1);
- const nextDate = addDays(selectedDate, 1);
+ // Header sizer: measure each slot's text width, interpolate during swipe
+ const slotWidth0 = useSharedValue(0);
+ const slotWidth1 = useSharedValue(0);
+ const slotWidth2 = useSharedValue(0);
+ const slotWidths = [slotWidth0, slotWidth1, slotWidth2] as const;
- const today = startOfDay();
+ const sizerStyle = useAnimatedStyle(() => {
+ const ws = [slotWidth0.value, slotWidth1.value, slotWidth2.value];
+ const center = centerSlot.value;
+ const p = translateX.value / screenWidth;
+ const absP = Math.min(Math.abs(p), 1);
+ const target = p < 0 ? (center + 1) % 3 : (center + 2) % 3;
+ const w = ws[center] * (1 - absP) + ws[target] * absP;
+ return { width: w > 0 ? w : undefined };
+ });
- const canGoNext = nextDate <= today;
+ // Derived
+ const canGoNext = addDays(selectedDate, 1) <= today;
+ canGoNextSV.value = canGoNext;
- const commitDate = (timestamp: number) => {
- setSelectedDate(new Date(timestamp));
- requestAnimationFrame(() => {
- translateX.value = 0;
- });
+ // Data lifting — 1 query for entire range
+ const entriesByDate = useDaysEntries(selectedDate);
+
+ // After swipe: recycle the off-screen slot, update selectedDate
+ const afterSwipe = (newCenter: number, direction: "left" | "right") => {
+ const dates = slotDatesRef.current;
+ const next = [...dates] as [Date, Date, Date];
+
+ if (direction === "left") {
+ const recycleSlot = (newCenter + 1) % 3;
+ next[recycleSlot] = addDays(dates[newCenter], 1);
+ } else {
+ const recycleSlot = (newCenter + 2) % 3;
+ next[recycleSlot] = addDays(dates[newCenter], -1);
+ }
+
+ slotDatesRef.current = next;
+ setSlotDates(next);
+ setSelectedDate(dates[newCenter]);
};
const handleSwipeEnd = (translationX: number) => {
- if (translationX < -SWIPE_THRESHOLD && canGoNext) {
- const ts = nextDate.getTime();
- translateX.value = withTiming(-screenWidth, { duration: 200 }, () => {
- runOnJS(commitDate)(ts);
+ if (translationX < -SWIPE_THRESHOLD && canGoNextSV.value) {
+ translateX.value = withTiming(-screenWidth, { duration: 200 }, (finished) => {
+ if (!finished) return;
+ const newCenter = (centerSlot.value + 1) % 3;
+ centerSlot.value = newCenter;
+ translateX.value = 0;
+ runOnJS(afterSwipe)(newCenter, "left");
});
} else if (translationX > SWIPE_THRESHOLD) {
- const ts = prevDate.getTime();
- translateX.value = withTiming(screenWidth, { duration: 200 }, () => {
- runOnJS(commitDate)(ts);
+ translateX.value = withTiming(screenWidth, { duration: 200 }, (finished) => {
+ if (!finished) return;
+ const newCenter = (centerSlot.value + 2) % 3;
+ centerSlot.value = newCenter;
+ translateX.value = 0;
+ runOnJS(afterSwipe)(newCenter, "right");
});
} else {
translateX.value = withTiming(0, { duration: 200 });
@@ -80,22 +130,16 @@ export default function DaysScreen() {
runOnJS(handleSwipeEnd)(event.translationX);
});
- const stripStyle = useAnimatedStyle(() => ({
- transform: [{ translateX: translateX.value }],
- }));
-
- const currentHeaderStyle = useAnimatedStyle(() => {
- const p = translateX.value / screenWidth;
- return { transform: [{ translateX: p * glassWidth.value }] };
- });
- const prevHeaderStyle = useAnimatedStyle(() => {
- const p = translateX.value / screenWidth;
- return { transform: [{ translateX: (p - 1) * glassWidth.value }] };
- });
- const nextHeaderStyle = useAnimatedStyle(() => {
- const p = translateX.value / screenWidth;
- return { transform: [{ translateX: (p + 1) * glassWidth.value }] };
- });
+ // Calendar picker: reset all slots
+ const handleCalendarDate = (date: Date) => {
+ const d = startOfDay(date);
+ const newDates: [Date, Date, Date] = [addDays(d, -1), d, addDays(d, 1)];
+ slotDatesRef.current = newDates;
+ setSlotDates(newDates);
+ setSelectedDate(d);
+ centerSlot.value = 1;
+ translateX.value = 0;
+ };
return (
<>
@@ -104,6 +148,21 @@ export default function DaysScreen() {
headerLargeTitleEnabled: false,
headerTitle: () => (
setShowCalendar(true)}>
+ {/* 幅計測用(不可視・GlassView外) */}
+
+ {slotDates.map((date, i) => (
+ {
+ slotWidths[i].value = e.nativeEvent.layout.width;
+ }}
+ >
+ {formatDateDays(date)}
+
+ ))}
+
+
-
- {formatDateDays(selectedDate)}
-
+ {/* 幅アニメーション用 sizer */}
+
-
- {formatDateDays(prevDate)}
-
-
-
- {formatDateDays(selectedDate)}
-
-
- {canGoNext && (
-
- {formatDateDays(nextDate)}
-
- )}
+ {slotDates.map((date, i) => (
+
+ ))}
@@ -146,27 +202,18 @@ export default function DaysScreen() {
/>
-
-
-
-
-
-
-
-
-
- {canGoNext && (
-
+ {slotDates.map((date, i) => (
+
-
-
- )}
+
+
+ ))}
@@ -175,7 +222,7 @@ export default function DaysScreen() {
@@ -193,6 +240,14 @@ const styles = StyleSheet.create({
paddingVertical: 12,
borderRadius: 100,
},
+ headerMeasure: {
+ position: "absolute",
+ opacity: 0,
+ pointerEvents: "none",
+ },
+ headerSizer: {
+ height: 20,
+ },
headerClip: {
...StyleSheet.absoluteFillObject,
overflow: "hidden",
@@ -200,9 +255,6 @@ const styles = StyleSheet.create({
justifyContent: "center",
borderRadius: 100,
},
- headerSizer: {
- opacity: 0,
- },
headerLabel: {
position: "absolute",
width: 300,
@@ -216,12 +268,4 @@ const styles = StyleSheet.create({
pager: {
flex: 1,
},
- page: {
- ...StyleSheet.absoluteFillObject,
- },
- dot: {
- width: 40,
- height: 40,
- borderRadius: 20,
- },
});
diff --git a/src/components/days/days-card.tsx b/src/components/days/days-card.tsx
index 2c416a7..e2171c9 100644
--- a/src/components/days/days-card.tsx
+++ b/src/components/days/days-card.tsx
@@ -1,85 +1,76 @@
-import { useTranslation } from "react-i18next";
import { PlatformColor } from "react-native";
-import { Button, HStack, Image, Section, Spacer, Text as SText, VStack } from "@expo/ui/swift-ui";
-import { font, foregroundStyle, listRowSeparator, opacity } from "@expo/ui/swift-ui/modifiers";
+import { Button, HStack, Image, Rectangle, Section, Spacer, Text } from "@expo/ui/swift-ui";
+import {
+ font,
+ foregroundStyle,
+ frame,
+ lineLimit,
+ listRowSeparator,
+ padding,
+} from "@expo/ui/swift-ui/modifiers";
+import { useRouter } from "expo-router";
-import { EntryDetailObj } from "@/db/queries/entries";
-import { JournalObj } from "@/db/schemas";
-import { deserializeValue } from "@/hooks/entry/use-entry";
+import { type DailyEntryObj } from "@/db/queries/entries";
import { formatTime } from "@/utils/date";
+import { buildPreviewEntry } from "@/utils/entry/preview";
-import { EntryFieldItem } from "../entry/entry-field-item";
-
-const PREVIEW_COUNT = 2;
+import { secondary } from "../entry/entry-row";
type Props = {
- entry: EntryDetailObj & { journal: JournalObj };
- isFirstOfJournal: boolean;
- isExpanded: boolean;
- onToggleExpand: () => void;
+ journalEntries: DailyEntryObj[];
};
/**
- * Days画面のエントリーカード
+ * Days画面 — ジャーナル単位のタイムラインセクション
*/
-export function DaysCard({ entry, isFirstOfJournal, isExpanded, onToggleExpand }: Props) {
- const { t } = useTranslation();
- const { journal, values, createdAt } = entry;
- const sorted = [...values].sort((a, b) => a.field.sortOrder - b.field.sortOrder);
- const hasMore = sorted.length > PREVIEW_COUNT;
- const visibleFields = isExpanded ? sorted : sorted.slice(0, PREVIEW_COUNT);
+export function DaysCard({ journalEntries }: Props) {
+ const router = useRouter();
+
+ const { journal } = journalEntries[0];
return (
- {isFirstOfJournal && (
-
-
-
- {journal.name}
-
-
- )}
-
- {formatTime(createdAt)}
+
+
+
+ {journal.name}
+
}
>
- {visibleFields.map((v, i) => {
- const isFading = !isExpanded && hasMore && i === visibleFields.length - 1;
- if (isFading) {
- return (
-
-
-
- );
- }
+ {journalEntries.map((entry) => {
+ const preview = buildPreviewEntry(entry);
return (
-
+
);
})}
- {hasMore && (
-
- )}
);
}
diff --git a/src/components/days/days-header-slot.tsx b/src/components/days/days-header-slot.tsx
new file mode 100644
index 0000000..46b6e5f
--- /dev/null
+++ b/src/components/days/days-header-slot.tsx
@@ -0,0 +1,52 @@
+import { PlatformColor, StyleSheet, Text } from "react-native";
+import Animated, { type SharedValue, useAnimatedStyle } from "react-native-reanimated";
+
+import { slotDiff } from "./slot-diff";
+
+type Props = {
+ slotIndex: number;
+ centerSlot: SharedValue;
+ translateX: SharedValue;
+ screenWidth: number;
+ glassWidth: SharedValue;
+ label: string;
+};
+
+/**
+ * ヘッダー日付のローテーティングスロット
+ */
+export const DaysHeaderSlot = ({
+ slotIndex,
+ centerSlot,
+ translateX,
+ screenWidth,
+ glassWidth,
+ label,
+}: Props) => {
+ const style = useAnimatedStyle(() => {
+ const diff = slotDiff(slotIndex, centerSlot.value);
+ const p = translateX.value / screenWidth;
+ return {
+ transform: [{ translateX: (diff + p) * glassWidth.value }],
+ };
+ });
+
+ return (
+
+ {label}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ headerLabel: {
+ position: "absolute",
+ width: 300,
+ alignItems: "center",
+ },
+ headerText: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: PlatformColor("label"),
+ },
+});
diff --git a/src/components/days/days-page-slot.tsx b/src/components/days/days-page-slot.tsx
new file mode 100644
index 0000000..c4cd909
--- /dev/null
+++ b/src/components/days/days-page-slot.tsx
@@ -0,0 +1,41 @@
+import { StyleSheet } from "react-native";
+import Animated, { type SharedValue, useAnimatedStyle } from "react-native-reanimated";
+
+import { slotDiff } from "./slot-diff";
+
+type Props = {
+ slotIndex: number;
+ centerSlot: SharedValue;
+ translateX: SharedValue;
+ screenWidth: number;
+ children: React.ReactNode;
+};
+
+/**
+ * ローテーティングバッファのページスロット
+ * centerSlot と translateX から自身の位置を算出する
+ */
+export const DaysPageSlot = ({
+ slotIndex,
+ centerSlot,
+ translateX,
+ screenWidth,
+ children,
+}: Props) => {
+ const style = useAnimatedStyle(() => {
+ const diff = slotDiff(slotIndex, centerSlot.value);
+ return {
+ transform: [{ translateX: diff * screenWidth + translateX.value }],
+ };
+ });
+
+ return (
+ {children}
+ );
+};
+
+const styles = StyleSheet.create({
+ page: {
+ ...StyleSheet.absoluteFillObject,
+ },
+});
diff --git a/src/components/days/days-view.tsx b/src/components/days/days-view.tsx
index 7e34991..84bcb4e 100644
--- a/src/components/days/days-view.tsx
+++ b/src/components/days/days-view.tsx
@@ -1,39 +1,38 @@
-import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PlatformColor, Text, View } from "react-native";
import { Host, List } from "@expo/ui/swift-ui";
-import { animation, Animation, frame } from "@expo/ui/swift-ui/modifiers";
-import { useLiveQuery } from "drizzle-orm/expo-sqlite";
+import { frame, listSectionSpacing, listStyle } from "@expo/ui/swift-ui/modifiers";
-import { getEntriesByDateQuery } from "@/db/queries/entries";
+import { type DailyEntryObj } from "@/db/queries/entries";
import { DaysCard } from "./days-card";
import { DaysLLMReflection } from "./days-llm-reflection";
+/** エントリーをジャーナルごとにグループ化 */
+const groupByJournal = (entries: DailyEntryObj[]): [string, DailyEntryObj[]][] => {
+ const map = new Map();
+ for (const entry of entries) {
+ const id = entry.journal.id;
+ const group = map.get(id);
+ if (group) group.push(entry);
+ else map.set(id, [entry]);
+ }
+ return [...map.entries()];
+};
+
type Props = {
- /**
- * 日付
- */
+ /** 日付 */
date: Date;
+ /** その日のエントリー(親から渡される) */
+ entries?: DailyEntryObj[];
};
/**
* Days画面 — 指定日のエントリー一覧をスクロール表示
*/
-export function DaysView({ date }: Props) {
+export function DaysView({ date, entries }: Props) {
const { t } = useTranslation();
- const { data: entries } = useLiveQuery(getEntriesByDateQuery(date), [date.getTime()]);
- const [expandedIds, setExpandedIds] = useState>(new Set());
-
- const toggleExpand = (id: string) => {
- setExpandedIds((prev) => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- };
if (!entries || entries.length === 0) {
return (
@@ -56,24 +55,17 @@ export function DaysView({ date }: Props) {
return (
-
+
- {entries.map((entry, index) => (
- e.journal.id === entry.journal.id) === index
- }
- isExpanded={expandedIds.has(entry.id)}
- onToggleExpand={() => toggleExpand(entry.id)}
- />
+ {groupByJournal(entries).map(([journalId, journalEntries]) => (
+
))}
diff --git a/src/components/days/slot-diff.ts b/src/components/days/slot-diff.ts
new file mode 100644
index 0000000..f247aeb
--- /dev/null
+++ b/src/components/days/slot-diff.ts
@@ -0,0 +1,8 @@
+/** 3 スロットの循環差分(-1, 0, +1) */
+export const slotDiff = (slotIndex: number, center: number) => {
+ "worklet";
+ let d = slotIndex - center;
+ if (d > 1) d -= 3;
+ if (d < -1) d += 3;
+ return d;
+};
diff --git a/src/components/entry/entry-list-view.tsx b/src/components/entry/entry-list-view.tsx
index d823974..2f9bbd2 100644
--- a/src/components/entry/entry-list-view.tsx
+++ b/src/components/entry/entry-list-view.tsx
@@ -1,7 +1,8 @@
+import { useEffect, useState } from "react";
import { PlatformColor, Pressable, StyleSheet, View } from "react-native";
import { Host, List, Section } from "@expo/ui/swift-ui";
-import { frame, listStyle } from "@expo/ui/swift-ui/modifiers";
+import { animation, Animation, frame, listStyle, opacity } from "@expo/ui/swift-ui/modifiers";
import { GlassView } from "expo-glass-effect";
import { useRouter } from "expo-router";
import { SymbolView } from "expo-symbols";
@@ -46,6 +47,14 @@ export function EntryListView({
bookmarkOnly,
});
+ const [fade, setFade] = useState(1);
+
+ useEffect(() => {
+ setFade(0.25);
+ const id = requestAnimationFrame(() => setFade(1));
+ return () => cancelAnimationFrame(id);
+ }, [activeJournalId, bookmarkOnly]);
+
return (
indices.forEach(async (i) => await deleteEntry(entries[i].id))}
+ modifiers={[opacity(fade), animation(Animation.easeInOut({ duration: 0.1 }), fade)]}
>
{entries.map((entry) => (
diff --git a/src/components/entry/entry-row.tsx b/src/components/entry/entry-row.tsx
index 0968bdb..352ab3e 100644
--- a/src/components/entry/entry-row.tsx
+++ b/src/components/entry/entry-row.tsx
@@ -9,7 +9,7 @@ import { bookmarkEntry, deleteEntry } from "@/db/queries/entries";
import { formatDate } from "@/utils/date";
import { PreviewEntryObj } from "@/utils/entry/preview";
-const secondary = foregroundStyle({ type: "hierarchical", style: "secondary" });
+export const secondary = foregroundStyle({ type: "hierarchical", style: "secondary" });
type Props = {
/** ジャーナル */
@@ -48,6 +48,11 @@ export function EntryRow({ journalName, entry }: Props) {
+