Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ポモドーロタイマーの機能 #134

Merged
merged 16 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
14 changes: 14 additions & 0 deletions src/main/services/UserPreferenceStoreServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export class UserPreferenceStoreServiceImpl implements IUserPreferenceStoreServi

muteWhileInMeeting: true,

workingMinutes: 25,
breakMinutes: 5,
notifyAtPomodoroComplete: {
announce: true,
sendNotification: false,
template: '{SESSION}が終了しました。',
},
notifyBeforePomodoroComplete: {
announce: false,
sendNotification: true,
template: '{SESSION}終了まであと{TIME}分です。',
},
notifyBeforePomodoroCompleteTimeOffset: 10,

updated: this.dateUtil.getCurrentDate(),
};
}
Expand Down
82 changes: 72 additions & 10 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import * as menu from './components/menu';
import { SettingPage } from './pages/SettingPage';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ActivityUsagePage } from './pages/ActivityUsagePage';
import { PomodoroTimerPage } from './pages/PomodoroTimerPage';
import { INotificationService } from './services/INotificationService';
import { PomodoroTimerContextProvider } from './components/PomodoroTimerContextProvider';

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -93,6 +96,62 @@ const App = (): JSX.Element => {
};
}, []);

useEffect(() => {
const notificationService = rendererContainer.get<INotificationService>(
TYPES.NotificationSubscriber
);
// ハンドラ
const subscriber = (_event, text: string): void => {
notificationService.sendNotification(text, 1 * 60 * 1000);
};
// コンポーネントがマウントされたときに IPC のハンドラを設定
console.log('register notification handler');
const unsubscribe = window.electron.ipcRenderer.on(IpcChannel.NOTIFICATION_NOTIFY, subscriber);
return () => {
// コンポーネントがアンマウントされたときに解除
console.log('unregister notification handler');
unsubscribe();
};
}, []);

// useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここはたぶん、コメントアウトを戻すのを忘れたのではないかと思うので確認してください

// if (!userPreference) {
// return;
// }
// const speakEventService = rendererContainer.get<ISpeakEventService>(TYPES.SpeakEventSubscriber);
// const notificationService = rendererContainer.get<INotificationService>(
// TYPES.NotificationSubscriber
// );
// // ハンドラ
// const subscriber = (_event, details: PomodoroTimerDetails, forDisplayOnly: boolean): void => {
// if (forDisplayOnly) {
// return;
// }
// if (details.currentTime <= 0) {
// speakEventService.speak('終わり');
// return;
// }
// const currentMinutes = details.currentTime / (60 * 1000);
// if (currentMinutes == userPreference.sendNotificationTimeOffset) {
// notificationService.notify(
// userPreference.sendNotificationTextTemplate.replace('{TIME}', currentMinutes.toString()),
// 1 * 60 * 1000
// );
// }
// };
// // コンポーネントがマウントされたときに IPC のハンドラを設定
// console.log('register pomodoro handler');
// const unsubscribe = window.electron.ipcRenderer.on(
// IpcChannel.POMODORO_TIMER_CURRENT_DETAILS_NOTIFY,
// subscriber
// );
// return () => {
// // コンポーネントがアンマウントされたときに解除
// console.log('unregister pomodoro handler');
// unsubscribe();
// };
// }, [userPreference]);

if (theme === null) {
return <div>Loading...</div>;
}
Expand All @@ -107,17 +166,20 @@ const App = (): JSX.Element => {
<ThemeProvider theme={theme}>
<HashRouter>
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}>
<Box sx={{ display: 'flex' }}>
<DrawerAppBar />
<Box component="main" sx={{ width: '100%' }}>
<Toolbar />
<Routes>
<Route path={menu.MENU_TIMELINE.path} element={<TimelinePage />} />
<Route path={menu.MENU_SETTING.path} element={<SettingPage />} />
<Route path={menu.MENU_ACTIVITY_USAGE.path} element={<ActivityUsagePage />} />
</Routes>
<PomodoroTimerContextProvider>
<Box sx={{ display: 'flex' }}>
<DrawerAppBar />
<Box component="main" sx={{ width: '100%' }}>
<Toolbar />
<Routes>
<Route path={menu.MENU_TIMELINE.path} element={<TimelinePage />} />
<Route path={menu.MENU_SETTING.path} element={<SettingPage />} />
<Route path={menu.MENU_ACTIVITY_USAGE.path} element={<ActivityUsagePage />} />
<Route path={menu.MENU_POMODORO_TIMER.path} element={<PomodoroTimerPage />} />
</Routes>
</Box>
</Box>
</Box>
</PomodoroTimerContextProvider>
</SnackbarProvider>
</HashRouter>
</ThemeProvider>
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/components/DrawerAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const navItems: NavItem[] = [
text: menu.MENU_ACTIVITY_USAGE.name,
link: menu.MENU_ACTIVITY_USAGE.path,
},
{
text: menu.MENU_POMODORO_TIMER.name,
link: menu.MENU_POMODORO_TIMER.path,
},
];

const drawerWidth = 240;
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/components/PomodoroTimerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PomodoroTimerDetails, TimerSession } from '@shared/data/PomodoroTimerDetails';
import React from 'react';

type PomodoroTimerContextType = {
pomodoroTimerDetails: PomodoroTimerDetails | null;

setTimer: (timerSession: TimerSession) => void;
startTimer: () => void;
pauseTimer: () => void;
stopTimer: () => void;
};

const pomodoroTimerContext = React.createContext<PomodoroTimerContextType>({
pomodoroTimerDetails: null,

setTimer: () => {},
startTimer: () => {},
pauseTimer: () => {},
stopTimer: () => {},
});

export default pomodoroTimerContext;
213 changes: 213 additions & 0 deletions src/renderer/src/components/PomodoroTimerContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import rendererContainer from '../inversify.config';
import { ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import AppContext from './AppContext';
import { TYPES } from '@renderer/types';
import { PomodoroTimerDetails, TimerSession, TimerState } from '@shared/data/PomodoroTimerDetails';
import PomodoroTimerContext from './PomodoroTimerContext';
import { TimerManager } from '@shared/utils/TimerManager';
import { IUserPreferenceProxy } from '@renderer/services/IUserPreferenceProxy';
import { DateUtil } from '@shared/utils/DateUtil';
import { ISpeakEventService } from '@renderer/services/ISpeakEventService';
import { INotificationService } from '@renderer/services/INotificationService';
import { PomodoroNotificationSetting } from '@shared/data/PomodoroNotificationSetting';

export const PomodoroTimerContextProvider = ({
children,
}: {
children: ReactNode;
}): JSX.Element => {
const [pomodoroTimerDetails, setPomodoroTimerDetails] = useState<PomodoroTimerDetails | null>(
null
);
const [lastUpdated, setLastUpdate] = useState<Date | null>(null);

const { userDetails } = useContext(AppContext);

const TIMER_NAME = 'pomodoroTimer';
const timer = rendererContainer.get<TimerManager>(TYPES.TimerManager).get(TIMER_NAME);

const userPreferenceProxy = rendererContainer.get<IUserPreferenceProxy>(
TYPES.UserPreferenceProxy
);

const speakEventService = rendererContainer.get<ISpeakEventService>(TYPES.SpeakEventSubscriber);
const notificationService = rendererContainer.get<INotificationService>(
TYPES.NotificationSubscriber
);

const dateUtil = rendererContainer.get<DateUtil>(TYPES.DateUtil);

const setTimer = useCallback(
async (session: TimerSession): Promise<void> => {
if (userDetails == null) {
return;
}
const userPreference = await userPreferenceProxy.getOrCreate(userDetails.userId);
const initialMinutes =
session === TimerSession.WORK ? userPreference.workingMinutes : userPreference.breakMinutes;
setPomodoroTimerDetails({
session: session,
state: TimerState.STOPPED,
currentTime: initialMinutes * 60 * 1000,
});
},
[userDetails, userPreferenceProxy]
);

useEffect(() => {
setTimer(TimerSession.WORK);
}, [setTimer]);

const getNextSession = (session: TimerSession): TimerSession =>
session === TimerSession.WORK ? TimerSession.BREAK : TimerSession.WORK;

const sendNotification = useCallback(
(setting: PomodoroNotificationSetting): void => {
if (pomodoroTimerDetails == null) {
return;
}
const session = pomodoroTimerDetails.session === TimerSession.WORK ? '作業時間' : '休憩時間';
const time = Math.ceil(pomodoroTimerDetails.currentTime / (60 * 1000));
const text = setting.template
.replace('{TIME}', time.toString())
.replace('{SESSION}', session);
console.log(text);
if (setting.announce) {
speakEventService.speak(text);
}
if (setting.sendNotification) {
notificationService.sendNotification(text, 60 * 1000);
}
},
[notificationService, pomodoroTimerDetails, speakEventService]
);

const startTimer = useCallback((): void => {
setPomodoroTimerDetails((details) =>
details != null
? {
...details,
state: TimerState.RUNNING,
}
: null
);
}, []);

const pauseTimer = useCallback((): void => {
if (pomodoroTimerDetails == null) {
return;
}
if (pomodoroTimerDetails.state != TimerState.RUNNING) {
return;
}
timer.clear();

setPomodoroTimerDetails((details) => {
if (details == null || details.state !== TimerState.RUNNING) {
return details;
}
const diffTime = lastUpdated
? dateUtil.getCurrentDate().getTime() - lastUpdated.getTime()
: 0;
return {
...details,
state: TimerState.PAUSED,
currentTime: details.currentTime - diffTime,
};
});
setLastUpdate(null);
}, [dateUtil, lastUpdated, pomodoroTimerDetails, timer]);

const stopTimer = (): void => {
if (pomodoroTimerDetails == null) {
return;
}
timer.clear();

setTimer(pomodoroTimerDetails.session);
setLastUpdate(null);
};

// 次の時間の更新のセット
useEffect(() => {
if (pomodoroTimerDetails == null) {
return;
}

if (pomodoroTimerDetails.state !== TimerState.RUNNING) {
return;
}

timer.clear();

if (pomodoroTimerDetails.currentTime > 0) {
setLastUpdate(dateUtil.getCurrentDate());

// 整数秒になるように時間を更新する
const intervalMs = pomodoroTimerDetails.currentTime % 1000 || 1000;
// 次の時間の更新をセットする
timer.addTimeout(() => {
setPomodoroTimerDetails((details) =>
details != null
? {
...details,
currentTime: details.currentTime - intervalMs,
}
: null
);
}, intervalMs);
}
}, [dateUtil, pomodoroTimerDetails, timer]);

// 通知とセッションの切り替え
useEffect(() => {
if (userDetails == null || pomodoroTimerDetails == null) {
return;
}

if (pomodoroTimerDetails.state !== TimerState.RUNNING) {
return;
}

const timerEvent = async (): Promise<void> => {
const userPreference = await userPreferenceProxy.getOrCreate(userDetails.userId);

// 残り時間が0秒になったときの処理
if (pomodoroTimerDetails.currentTime <= 0) {
sendNotification(userPreference.notifyAtPomodoroComplete);
const session = getNextSession(pomodoroTimerDetails.session);
await setTimer(session);
startTimer();
return;
}

// 残り時間n秒前の処理
const offsetMs = userPreference.notifyBeforePomodoroCompleteTimeOffset * 60 * 1000;
if (pomodoroTimerDetails.currentTime == offsetMs) {
sendNotification(userPreference.notifyBeforePomodoroComplete);
}
};
timerEvent();
}, [
pomodoroTimerDetails,
sendNotification,
setTimer,
startTimer,
userDetails,
userPreferenceProxy,
]);

return (
<PomodoroTimerContext.Provider
value={{
pomodoroTimerDetails,
setTimer,
startTimer,
pauseTimer,
stopTimer,
}}
>
{children}
</PomodoroTimerContext.Provider>
);
};
1 change: 1 addition & 0 deletions src/renderer/src/components/menu.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const MENU_TIMELINE = { path: '/', name: 'タイムライン' };
export const MENU_SETTING = { path: '/setting', name: '設定' };
export const MENU_ACTIVITY_USAGE = { path: '/activity_usage', name: 'アプリ使用時間' };
export const MENU_POMODORO_TIMER = { path: '/pomodoro_timer', name: 'ポモドーロタイマー' };
Loading
Loading