Final push before system formatting
This commit is contained in:
BIN
AdvOpsys/AdvOpsys.pdf
Normal file
BIN
AdvOpsys/AdvOpsys.pdf
Normal file
Binary file not shown.
106
AdvOpsys/notes/report/1.tex
Normal file
106
AdvOpsys/notes/report/1.tex
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
\section{Boot Process Background}
|
||||||
|
|
||||||
|
This section gives background context for the boot process before the kernel-specific implementation described in the rest of the report. It covers the firmware stages, bootloader responsibilities, memory layout, and the effect of virtualization on booting.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\subsection{Power-On Self-Test}
|
||||||
|
When a computer is powered on or reset, the processor begins execution from a firmware-defined reset vector. At this point, the operating system is not running yet. The first responsibility belongs to the system firmware, either Basic Input/Output system (BIOS) on older x86 systems or Unified Extensive Firmware Interface (UEFI) on newer systems \cite{littleosbook, wikipediaPOST}.
|
||||||
|
|
||||||
|
One of the earliest firmware tasks is the Power-On Self-Test, usually shortened to POST. POST checks that the basic hardware needed to continue booting is present and responding \cite{manybutfinite}.
|
||||||
|
|
||||||
|
POST might include testing and verifying some of these components:
|
||||||
|
\begin{itemize}
|
||||||
|
\item Processors
|
||||||
|
\item Storage
|
||||||
|
\item Memory
|
||||||
|
\item Keyboard (Keyboard not found. Press f1 to continue.)
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Note: The exact list depends on the system \cite{techtargetPOST}.
|
||||||
|
|
||||||
|
POST is important because later boot stages assume that the basic machine state is usable. If a required device fails or memory cannot be initialized, the firmware may stop the boot process and report the failure through screen output, status LEDs, or beep codes \cite{techtargetPOST}.
|
||||||
|
|
||||||
|
The interaction between POST and hardware is therefore direct and low-level. Firmware initializes chipset state, configures memory controllers, discovers attached devices, and prepares enough of the platform that a bootloader can be found and executed. This is different from normal operating-system hardware management, because POST happens before the OS has loaded drivers or enabled its own abstractions.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\subsection{Boot Sequence After POST}
|
||||||
|
After a successful POST, the firmware chooses a boot device according to its configured boot order. On BIOS systems, this usually means reading the first sector of a bootable disk into memory and jumping to it. On UEFI systems, the firmware instead loads an EFI application from a filesystem on the EFI System Partition \cite{cyberraidenUEFIBoot, manybutfinite}.
|
||||||
|
|
||||||
|
The high-level sequence after POST is:
|
||||||
|
\begin{enumerate}
|
||||||
|
\item firmware completes hardware initialization,
|
||||||
|
\item firmware selects a boot device,
|
||||||
|
\item the first bootloader stage or EFI boot application is loaded,
|
||||||
|
\item the bootloader prepares the environment expected by the kernel,
|
||||||
|
\item the kernel image is loaded into memory,
|
||||||
|
\item control is transferred to the kernel entry point.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
In a BIOS boot flow, the first loaded code is small because the initial boot sector is limited to 512 bytes. This usually forces the bootloader to use multiple stages. The first stage is responsible for loading a larger second stage, and the second stage can then parse filesystems, load the kernel, and prepare boot information\cite{osdevBootSequence, manybutfinite}.
|
||||||
|
|
||||||
|
In a UEFI boot flow, the firmware provides more services before the operating system starts. A UEFI bootloader can use firmware-provided filesystem and device services, and it is loaded as a normal executable rather than as raw code from a fixed disk sector. This makes UEFI bootloaders more flexible, but the kernel must still eventually take control and stop depending on firmware runtime assumptions \cite{cyberraidenUEFIBoot}.
|
||||||
|
|
||||||
|
\subsection{Bootloaders}
|
||||||
|
A bootloader is the bridge between firmware and the operating-system kernel. Its purpose is not only to start the kernel, but also to place the machine into a state the kernel understands \cite{ionosBootloader, littleosbook}.
|
||||||
|
|
||||||
|
Common bootloader responsibilities include:
|
||||||
|
\begin{itemize}
|
||||||
|
\item locating and loading the kernel image,
|
||||||
|
\item loading additional modules or initrd files,
|
||||||
|
\item obtaining a memory map from the firmware,
|
||||||
|
\item selecting or setting a graphics mode,
|
||||||
|
\item preparing boot information structures,
|
||||||
|
\item switching CPU mode when required,
|
||||||
|
\item transferring control to the kernel entry point.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Different bootloaders provide different levels of support. GRUB is a general-purpose bootloader with filesystem support, configuration files, multiboot support, and broad hardware compatibility. Limine is a modern boot protocol and bootloader often used in hobby OS development because it provides a clear boot protocol and supports both BIOS and UEFI systems\cite{osdevBootSequence,limineProtocol, wikipediaLimine}. A manually implemented bootloader gives full control over the boot path, but requires more work and exposes the project to more early-stage hardware and filesystem details.
|
||||||
|
|
||||||
|
The choice of bootloader depends on the goals of the operating-system project. For a kernel-focused project, using an existing bootloader is usually the most practical choice because it avoids spending most of the work on disk loading, filesystem parsing, firmware differences, and CPU mode transitions. For a project specifically about bootstrapping, implementing a bootloader manually can be useful because it exposes the details normally hidden by existing tools \cite{osdevBootSequence}.
|
||||||
|
|
||||||
|
Manual bootloader implementation is challenging because the environment is very limited. In a BIOS first-stage bootloader, code size is constrained, there is no standard library, only a small amount of state is initialized, and disk access must use firmware interrupts or direct hardware access. The bootloader must also be careful about where it places itself, the kernel, stacks, tables, and temporary buffers in memory.
|
||||||
|
|
||||||
|
\subsection{Memory Layout in the Boot Process}
|
||||||
|
In a traditional i386 BIOS boot process, early memory layout is constrained by legacy conventions. The first MiB of memory contains several important regions, including the interrupt vector table, BIOS data area, conventional memory, video memory, and firmware regions\cite{osdevMemoryMapX86}.
|
||||||
|
|
||||||
|
The bootloader and kernel must avoid overwriting memory that is already used by firmware, hardware mappings, or the bootloader itself. In small BIOS examples, a common simple kernel load address is \texttt{0x10000}. This address is above the lowest BIOS data structures and gives the kernel more room than the original boot sector area, while still being within conventional memory that is easy to access in early boot stages. In this project, the linker script places the kernel at \texttt{1 MiB}, which is also a common protected-mode kernel load address and keeps the kernel away from the lowest legacy BIOS regions \cite{osdevMemoryMapX86}.
|
||||||
|
|
||||||
|
Choosing a fixed early kernel load address has practical implications:
|
||||||
|
\begin{itemize}
|
||||||
|
\item the bootloader needs to copy or load the kernel to a known address,
|
||||||
|
\item the kernel linker script must match the address where the kernel expects to run,
|
||||||
|
\item early stacks and temporary buffers must be placed so they do not overlap the kernel,
|
||||||
|
\item later memory management must identify which regions are already occupied.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Once the kernel has control, it can replace this simple early memory model with its own memory management. In this project, that later stage is represented by kernel heap initialization, page-aligned allocation, and paging setup.
|
||||||
|
|
||||||
|
\subsection{Boot Process in Modern Operating Systems}
|
||||||
|
Modern operating systems use more complex boot processes than small teaching kernels, but the same basic idea remains: firmware initializes the platform, a bootloader or boot manager loads the kernel, and the kernel takes control of the machine.
|
||||||
|
|
||||||
|
Linux systems commonly use firmware to start a bootloader such as GRUB or systemd-boot. The bootloader loads the kernel and an initramfs, passes a command line and boot information, and then transfers control to the Linux kernel. Windows systems use Windows Boot Manager, which loads the Windows OS loader before the kernel and core system components are started. macOS uses Apple's boot chain, which is tightly integrated with Apple hardware, APFS, Secure Boot policies, and system volume verification\cite{linuxX86BootProtocol,microsoftWindowsBootOptions,appleSiliconBootProcess,appleIntelBootProcess}.
|
||||||
|
|
||||||
|
Modern boot processes have changed because hardware and security requirements have changed. UEFI replaced many BIOS conventions, disks moved from MBR partitioning toward GPT, and Secure Boot introduced signature verification into the boot chain. Storage devices are also more complex than older BIOS-era disks, and modern systems often need early support for NVMe, encryption, graphics initialization, and platform security features.
|
||||||
|
|
||||||
|
The result is that modern booting is both more capable and more controlled. Firmware and bootloaders provide richer services, but the operating system must also participate in a stricter trust chain and handle more platform variation.
|
||||||
|
|
||||||
|
\subsection{Virtual Machines and Booting}
|
||||||
|
Booting inside a virtual machine follows the same conceptual stages as booting on physical hardware, but the hardware being initialized is virtual. The guest operating system still sees firmware, CPU state, memory, storage devices, timers, and interrupt controllers. However, those devices are provided or emulated by the hypervisor\cite{qemuPcMachine}.
|
||||||
|
|
||||||
|
The main difference is that a virtual machine does not start from real motherboard hardware. Instead, the hypervisor creates a virtual hardware environment and then starts the guest firmware inside it. The guest firmware performs a boot sequence that looks normal from inside the VM, but many device operations are handled by the hypervisor on the host \cite{hashnodeVMboot}.
|
||||||
|
|
||||||
|
The hypervisor has several roles in this process:
|
||||||
|
\begin{itemize}
|
||||||
|
\item allocating guest memory,
|
||||||
|
\item exposing virtual CPUs,
|
||||||
|
\item providing virtual disks and network devices,
|
||||||
|
\item emulating or virtualizing interrupt controllers and timers,
|
||||||
|
\item presenting BIOS or UEFI firmware to the guest,
|
||||||
|
\item handling privileged operations that cannot run directly on the host CPU.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Virtualized booting has advantages for operating-system development. It is faster to test than rebooting physical hardware, the virtual machine can be reset easily, debugging is easier with tools such as QEMU and GDB, and hardware behaviour is more reproducible. This is why the kernel in this project was tested through QEMU rather than directly on physical hardware.
|
||||||
|
|
||||||
|
There are also limitations. Emulated hardware may not behave exactly like real hardware, and some devices are simplified compared to physical machines. For example, PC speaker output in QEMU can be limited, which affected the clarity of fast note changes in the music player. Even so, virtualization is highly useful for kernel development because it provides a controlled environment for testing boot, interrupts, memory management, and device interaction\cite{qemuPcMachine, hashnodeVMboot}.
|
||||||
122
AdvOpsys/notes/report/2.tex
Normal file
122
AdvOpsys/notes/report/2.tex
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
\section{Boot Process and Processor Setup}
|
||||||
|
|
||||||
|
The first stage of the project established a more complete early boot path for the kernel. Before this work, the kernel entered C code, initialized the VGA terminal, printed \texttt{Hello, World!}, and then remained in an infinite halt loop. The terminal output in this stage used direct VGA text-mode writes rather than BIOS text services, which is the normal approach once the kernel is running in protected mode \cite{osdevPrintingToScreen}. The visible output remained the same after this stage, but the processor setup became more explicit and reliable.
|
||||||
|
|
||||||
|
The main change was the addition of a minimal Global Descriptor Table for the i386 kernel. Even though the kernel uses a flat memory model, protected mode still requires valid segment descriptors. The GDT therefore gives the processor valid code and data segment definitions before the kernel continues into higher-level initialization\cite{osdevGdtTutorial}.
|
||||||
|
|
||||||
|
\subsection{Kernel Entry Flow}
|
||||||
|
After the GDT work, the early kernel flow became:
|
||||||
|
\begin{enumerate}
|
||||||
|
\item the bootloader transfers control to the kernel entry point,
|
||||||
|
\item the kernel begins execution in C,
|
||||||
|
\item the GDT is initialized and loaded,
|
||||||
|
\item segment registers are reloaded,
|
||||||
|
\item the VGA terminal is initialized,
|
||||||
|
\item \texttt{Hello, World!} is written to the screen,
|
||||||
|
\item the kernel enters an infinite halt loop.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
This keeps processor setup before terminal setup. Later stages build on this ordering by adding interrupts, memory management, timing, and applications after the low-level CPU state has been initialized.
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{GDT Layout}
|
||||||
|
The implemented GDT contains three descriptors:
|
||||||
|
\begin{itemize}
|
||||||
|
\item a null descriptor,
|
||||||
|
\item a kernel code segment descriptor,
|
||||||
|
\item a kernel data segment descriptor.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
The code and data segments both use base address \texttt{0x00000000}, a 4 GiB address span, 4 KiB granularity, and ring 0 privilege level. The code segment uses selector \texttt{0x08}, while the data segment uses selector \texttt{0x10}. This matches the flat protected-mode layout commonly used in small i386 kernels \cite{osdevGdtTutorial,intelSdm}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void GdtInitialize(void) {
|
||||||
|
gdtDescriptor.size = sizeof(gdtEntries) - 1;
|
||||||
|
gdtDescriptor.offset = (uint32_t)&gdtEntries;
|
||||||
|
|
||||||
|
GdtSetEntry(0, 0, 0, 0, 0);
|
||||||
|
GdtSetEntry(1, 0, 0x000FFFFF, 0x9A, 0xCF);
|
||||||
|
GdtSetEntry(2, 0, 0x000FFFFF, 0x92, 0xCF);
|
||||||
|
|
||||||
|
GdtFlush((uint32_t)&gdtDescriptor);
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Minimal GDT initialization with null, kernel code, and kernel data descriptors.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\subsection{Descriptor Construction}
|
||||||
|
The GDT setup is split between C and assembly. The C code builds the descriptor table and prepares the GDT descriptor that contains the table size and address. The helper that creates each descriptor splits the base and limit into the fields expected by the processor \cite{osdevGdtTutorial,intelSdm}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
static void GdtSetEntry(
|
||||||
|
uint32_t index,
|
||||||
|
uint32_t base,
|
||||||
|
uint32_t limit,
|
||||||
|
uint8_t access,
|
||||||
|
uint8_t granularity
|
||||||
|
) {
|
||||||
|
gdtEntries[index].base_low = (uint16_t)(base & 0xFFFF);
|
||||||
|
gdtEntries[index].base_middle = (uint8_t)((base >> 16) & 0xFF);
|
||||||
|
gdtEntries[index].base_high = (uint8_t)((base >> 24) & 0xFF);
|
||||||
|
|
||||||
|
gdtEntries[index].limit_low = (uint16_t)(limit & 0xFFFF);
|
||||||
|
gdtEntries[index].granularity = (uint8_t)((limit >> 16) & 0x0F);
|
||||||
|
gdtEntries[index].granularity |= (uint8_t)(granularity & 0xF0);
|
||||||
|
gdtEntries[index].access = access;
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Building an x86 GDT descriptor from base, limit, access, and granularity fields.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Reloading Segment Registers}
|
||||||
|
Loading the GDT with \texttt{lgdt} is not enough by itself. The CPU caches segment descriptor information in the segment registers, so the kernel must reload those registers after installing the new table. The assembly routine performs the architecture-specific part: it loads the GDT, performs a far jump to reload \texttt{cs}, and then reloads the data segment registers\cite{osdevGdtTutorial}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{nasm}
|
||||||
|
GdtFlush:
|
||||||
|
mov eax, [esp + 4]
|
||||||
|
lgdt [eax]
|
||||||
|
|
||||||
|
jmp GDT_CODE_SELECTOR:.reload_cs
|
||||||
|
|
||||||
|
.reload_cs:
|
||||||
|
mov ax, GDT_DATA_SELECTOR
|
||||||
|
mov ds, ax
|
||||||
|
mov es, ax
|
||||||
|
mov fs, ax
|
||||||
|
mov gs, ax
|
||||||
|
mov ss, ax
|
||||||
|
ret
|
||||||
|
\end{minted}
|
||||||
|
\caption{Loading the GDT and refreshing the cached code and data segment registers.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\section{Build and Verification}
|
||||||
|
|
||||||
|
The build configuration was updated so that both the C GDT implementation and the assembly reload routine are compiled and linked into the kernel binary. New build artifacts were generated, including an updated kernel binary and bootable ISO image.
|
||||||
|
|
||||||
|
\subsection{Static Binary Inspection}
|
||||||
|
The generated kernel binary was inspected to confirm that the expected GDT setup sequence was present. The inspection showed a call to the GDT load routine, a far jump to selector \texttt{0x08}, and data segment reloads using selector \texttt{0x10}.
|
||||||
|
|
||||||
|
\subsection{Runtime Debugging}
|
||||||
|
The GDT setup was also verified in QEMU using \texttt{gdb-multiarch}. Execution was stopped inside the GDT initialization and reload path. The GDT descriptor limit was \texttt{0x17}, which matches three 8-byte entries minus one. After the far jump, \texttt{cs} contained \texttt{0x08}; after the reload instructions, \texttt{ds}, \texttt{es}, \texttt{fs}, \texttt{gs}, and \texttt{ss} contained \texttt{0x10}.
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{State After Processor Setup}
|
||||||
|
|
||||||
|
At the end of this stage, the kernel:
|
||||||
|
\begin{itemize}
|
||||||
|
\item builds inside the development container,
|
||||||
|
\item includes a minimal i386 GDT implementation,
|
||||||
|
\item loads the GDT during startup,
|
||||||
|
\item reloads the code and data segment registers correctly,
|
||||||
|
\item still initializes the VGA terminal,
|
||||||
|
\item still prints \texttt{Hello, World!} after boot.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
This stage did not add new user-facing behaviour. Its purpose was to make the early processor state explicit so later kernel subsystems could be added on top of a controlled protected-mode setup.
|
||||||
|
|
||||||
|
Later stages changed the visible startup flow by initializing the terminal before later setup code prints diagnostics for interrupt, memory, and paging work. The important dependency from this stage remains that the GDT is loaded before the kernel relies on later interrupt and application behaviour.
|
||||||
185
AdvOpsys/notes/report/3.tex
Normal file
185
AdvOpsys/notes/report/3.tex
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
\newpage
|
||||||
|
|
||||||
|
\section{Interrupt Handling}
|
||||||
|
|
||||||
|
The next stage added interrupt support to the kernel. This required an Interrupt Descriptor Table so the processor has defined entry points for CPU exceptions and hardware interrupts. The implementation follows the same model used by the OSDev Wiki material, the \texttt{os-tutorial} project, and the lecturer-provided assignment files \cite{osdevInterruptsTutorial,fenollosaOsTutorial,assignmentFiles}.
|
||||||
|
|
||||||
|
Interrupt support changed the kernel from a purely linear boot program into a system that can react to events while it is running. CPU exceptions are handled through ISR stubs, hardware interrupts are handled through IRQ stubs, and both paths eventually reach C handlers with a saved register state.
|
||||||
|
|
||||||
|
\subsection{IDT Descriptor Setup}
|
||||||
|
Each IDT entry stores the handler address, the kernel code segment selector, and the descriptor attributes used by the processor. The handler address is split into low and high parts because this is the layout expected by the 32-bit x86 IDT format \cite{osdevInterruptsTutorial}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void IdtSetDescriptor(uint8_t vector, uint32_t interrupt, uint8_t flags) {
|
||||||
|
struct IdtEntry* descriptor = &idt[vector];
|
||||||
|
|
||||||
|
descriptor->interrupt_low = (uint32_t)interrupt & 0xFFFF;
|
||||||
|
descriptor->kernel_cs = GDT_CODE_SELECTOR;
|
||||||
|
descriptor->attributes = flags;
|
||||||
|
descriptor->interrupt_high = (uint32_t)interrupt >> 16;
|
||||||
|
descriptor->reserved = 0;
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Constructing an IDT gate for a specific interrupt vector.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{IDT Initialization}
|
||||||
|
During initialization, the kernel fills vectors \texttt{0} through \texttt{31} with CPU exception stubs and vectors \texttt{32} through \texttt{47} with hardware IRQ stubs. This means the implementation provides ISR coverage for all 32 standard CPU exception vectors, which is more complete than only defining the minimum three handlers required for basic testing. The PIC is then remapped before the IDT is loaded with \texttt{lidt} and interrupts are enabled with \texttt{sti}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void IdtInitialize(void) {
|
||||||
|
idtr.base = (uint32_t)&idt[0];
|
||||||
|
idtr.limit = (uint16_t)sizeof(struct IdtEntry) * IDT_ENTRIES - 1;
|
||||||
|
|
||||||
|
for (uint8_t iNum = 0; iNum < 32; iNum++) {
|
||||||
|
IdtSetDescriptor(iNum, (uint32_t)isr_stub_table[iNum], 0x8E);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint8_t iNum = 32; iNum < 48; iNum++) {
|
||||||
|
IdtSetDescriptor(iNum, (uint32_t)irq_stub_table[iNum - 32], 0x8E);
|
||||||
|
}
|
||||||
|
|
||||||
|
PicRemap();
|
||||||
|
__asm__ volatile ("lidt %0" : : "m"(idtr));
|
||||||
|
__asm__ volatile ("sti");
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Initializing the IDT with CPU exception and hardware IRQ entries.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{CPU Exceptions}
|
||||||
|
|
||||||
|
Basic Interrupt Service Routines were implemented for CPU exceptions. The low-level stubs save the CPU state and pass a \texttt{Registers} structure into the shared C handler. This lets the kernel inspect which exception occurred and print useful debugging information to the terminal. Instead of each ISR containing its own separate print logic, the individual stubs share the same C handler, which prints the interrupt number and message for the triggered exception.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void IsrHandler(struct Registers* registers) {
|
||||||
|
TerminalWriteString("\n=== INTERRUPT RECEIVED ===\n");
|
||||||
|
|
||||||
|
TerminalWriteString("Interrupt Number: ");
|
||||||
|
TerminalWriteUInt(registers->int_no);
|
||||||
|
TerminalWriteString("\n");
|
||||||
|
|
||||||
|
TerminalWriteString("Message: ");
|
||||||
|
if (registers->int_no < 32) {
|
||||||
|
TerminalWriteString(isrMessages[registers->int_no]);
|
||||||
|
} else {
|
||||||
|
TerminalWriteString("Unknown Interrupt");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
__asm__ volatile("cli; hlt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Shared CPU exception handler using the saved interrupt number.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
The handler stops the kernel after reporting the exception. This is appropriate for the current stage because exception recovery has not yet been implemented, and halting prevents the kernel from continuing after an undefined or unsafe processor state.
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{Hardware Interrupts}
|
||||||
|
|
||||||
|
Hardware interrupt support was added for IRQ0 through IRQ15. These interrupts come from devices through the Programmable Interrupt Controller. The PIC is remapped so that hardware IRQs begin at vector \texttt{32} instead of overlapping with the CPU exception vectors\cite{osdev8259Pic}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void PicRemap(void) {
|
||||||
|
OutPortByte(PIC1_COMMAND, 0x11);
|
||||||
|
OutPortByte(PIC2_COMMAND, 0x11);
|
||||||
|
|
||||||
|
OutPortByte(PIC1_DATA, 0x20);
|
||||||
|
OutPortByte(PIC2_DATA, 0x28);
|
||||||
|
|
||||||
|
OutPortByte(PIC1_DATA, 0x04);
|
||||||
|
OutPortByte(PIC2_DATA, 0x02);
|
||||||
|
|
||||||
|
OutPortByte(PIC1_DATA, 0x01);
|
||||||
|
OutPortByte(PIC2_DATA, 0x01);
|
||||||
|
|
||||||
|
OutPortByte(PIC1_DATA, 0x00);
|
||||||
|
OutPortByte(PIC2_DATA, 0x00);
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Remapping the PIC so IRQs use interrupt vectors \texttt{32} through \texttt{47}.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
The IRQ handler dispatches to a registered C handler when one exists. After the interrupt has been handled, the kernel sends an end-of-interrupt signal to the PIC. This tells the controller that the current interrupt has been processed and that new interrupts may be delivered.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void IrqHandler(struct Registers* registers) {
|
||||||
|
uint8_t irqNum = (uint8_t)(registers->int_no - 32);
|
||||||
|
|
||||||
|
if (interruptHandlers[registers->int_no] != 0) {
|
||||||
|
interruptHandlers[registers->int_no](registers);
|
||||||
|
} else if (irqNum != 0) {
|
||||||
|
TerminalWriteString("IRQ triggered: ");
|
||||||
|
TerminalWriteUInt(irqNum);
|
||||||
|
TerminalWriteString("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
PicSendEoi(irqNum);
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Dispatching hardware interrupts and acknowledging them with the PIC.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{Keyboard Input}
|
||||||
|
|
||||||
|
Keyboard support was implemented using IRQ1. The keyboard handler reads the scancode from port \texttt{0x60}, stores it in a small buffer, ignores key-release events, and translates key-press scancodes into ASCII through a lookup table\cite{osdevPs2Controller}.
|
||||||
|
|
||||||
|
The assignment describes the keyboard task as printing translated ASCII characters to the screen. In this project, the keyboard logic was extended slightly beyond a direct logger: the handler stores the latest translated ASCII character, and higher-level terminal or application code consumes it. This allows the same keyboard path to support the application menu and Snake input instead of only echoing characters immediately from the interrupt handler.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void KeyboardHandler(struct Registers* registers) {
|
||||||
|
(void) registers;
|
||||||
|
|
||||||
|
uint8_t scancode = InPortByte(KEYBOARD_DATA_PORT);
|
||||||
|
|
||||||
|
if (index < KEYBOARD_BUFFER_SIZE) {
|
||||||
|
keyboardBuffer[index] = scancode;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scancode & 0x80) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scancode < 128) {
|
||||||
|
char ascii = scancodeToAscii[scancode];
|
||||||
|
if (ascii != 0) {
|
||||||
|
lastKeyPressed = ascii;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Keyboard IRQ handler reading scancodes and storing the latest ASCII key.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
The most recent translated key can then be consumed by higher-level code through \texttt{GetLastKeyPressed()}. This becomes important in later stages where the application menu and Snake game need keyboard input without directly handling raw scancodes.
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{State After Interrupt Work}
|
||||||
|
|
||||||
|
At the end of this stage, the kernel:
|
||||||
|
\begin{itemize}
|
||||||
|
\item initializes and loads an Interrupt Descriptor Table,
|
||||||
|
\item handles CPU exceptions through ISR support,
|
||||||
|
\item supports hardware IRQs from IRQ0 to IRQ15,
|
||||||
|
\item remaps the PIC so hardware interrupts do not overlap with CPU exceptions,
|
||||||
|
\item dispatches hardware interrupts to registered handlers,
|
||||||
|
\item sends end-of-interrupt signals after IRQ handling,
|
||||||
|
\item handles keyboard input through IRQ1,
|
||||||
|
\item reads keyboard scancodes from port \texttt{0x60},
|
||||||
|
\item translates key presses into ASCII characters,
|
||||||
|
\item stores the latest key for later application-level input.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
This stage moved the kernel from static startup output to event-driven execution. The later PIT, music, menu, and Snake work all depend on this interrupt layer.
|
||||||
164
AdvOpsys/notes/report/4-5.tex
Normal file
164
AdvOpsys/notes/report/4-5.tex
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
\section{Memory Management}
|
||||||
|
|
||||||
|
The next stage added basic memory management to the kernel. This work introduced kernel heap initialization, paging, dynamic allocation, page-aligned allocation, and memory debugging output. Together these changes allow later kernel components to request memory dynamically instead of relying only on static storage\cite{assignmentFiles,osdevMemoryAllocation}.
|
||||||
|
|
||||||
|
\subsection{Kernel Heap Initialization}
|
||||||
|
The heap is initialized from the linker-provided end of the kernel image. The kernel stores this address in \texttt{last\_alloc}, then reserves a normal heap region below the page heap area. A separate page heap is placed below \texttt{0x400000}, with one descriptor byte per page-aligned allocation \cite{assignmentFiles}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void InitKernelMemory(uint32_t* kernel_end) {
|
||||||
|
uint32_t kernelEndAddr = (uint32_t)kernel_end;
|
||||||
|
|
||||||
|
last_alloc = kernelEndAddr + 0x1000;
|
||||||
|
heap_begin = last_alloc;
|
||||||
|
pheap_end = 0x400000;
|
||||||
|
pheap_begin = pheap_end - (MAX_PAGE_ALIGNED_ALLOCS * 4096);
|
||||||
|
heap_end = pheap_begin;
|
||||||
|
memset((char*)heap_begin, 0, heap_end - heap_begin);
|
||||||
|
pheap_desc = (uint8_t *)malloc(MAX_PAGE_ALIGNED_ALLOCS);
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Kernel heap initialization using the linker-provided kernel end symbol.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
This creates two allocation areas:
|
||||||
|
\begin{itemize}
|
||||||
|
\item a byte-addressed heap for normal \texttt{malloc()} allocations,
|
||||||
|
\item a page heap for 4 KiB page-aligned allocations through \texttt{pmalloc()}.
|
||||||
|
\end{itemize}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Paging}
|
||||||
|
Paging was implemented using a page directory at \texttt{0x400000} and page tables starting at \texttt{0x404000}. The setup identity-maps the first 4 MiB of memory and the 4 MiB region beginning at \texttt{0x400000}. This keeps virtual addresses equal to physical addresses for the early kernel environment while still enabling the processor's paging mechanism\cite{assignmentFiles,osdevSettingUpPaging}.
|
||||||
|
|
||||||
|
The paging setup writes the page directory address to \texttt{cr3} and sets the paging bit in \texttt{cr0}. After that point, memory references are translated through the page tables \cite{osdevSettingUpPaging,intelSdm}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void paging_enable() {
|
||||||
|
asm volatile("mov %%eax, %%cr3": :"a"(page_dir_loc));
|
||||||
|
asm volatile("mov %cr0, %eax");
|
||||||
|
asm volatile("orl $0x80000000, %eax");
|
||||||
|
asm volatile("mov %eax, %cr0");
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Enabling paging by loading \texttt{cr3} and setting the paging bit in \texttt{cr0}.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\subsection{Dynamic Allocation}
|
||||||
|
A simple heap allocator was implemented for \texttt{malloc()} and \texttt{free()}. Each allocation is preceded by a small metadata header containing the allocation status and size. New allocations advance \texttt{last\_alloc}, while freed blocks are marked as unused and may be reused by later allocations \cite{osdevMemoryAllocation}.
|
||||||
|
|
||||||
|
The allocator scans existing blocks before creating a new one. If it finds an unused block large enough for the request, that block is reactivated and returned. This was verified by freeing one allocation, requesting a smaller block, and observing that the allocator reused the same address.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
if(a->size >= size) {
|
||||||
|
a->status = 1;
|
||||||
|
memset(mem + sizeof(alloc_t), 0, size);
|
||||||
|
memory_used += a->size + sizeof(alloc_t) + 4;
|
||||||
|
return (char*)(mem + sizeof(alloc_t));
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Reusing a freed heap block when it is large enough for a new allocation.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\subsection{Memory Debugging}
|
||||||
|
The kernel also gained memory debugging output. \texttt{PrintMemoryLayout()} prints the amount of memory used, the amount free, the heap size, and the start and end addresses of both the normal heap and page heap. \texttt{TerminalWriteHex()} was added so addresses can be printed in hexadecimal instead of being confused with decimal values \cite{assignmentFiles}.
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{Programmable Interval Timer}
|
||||||
|
|
||||||
|
The Programmable Interval Timer was then added so the kernel can measure time and schedule delays. The PIT is configured on channel 0 at \texttt{1000 Hz}, so one timer tick corresponds approximately to one millisecond \cite{osdevPit}.
|
||||||
|
|
||||||
|
\subsection{PIT Initialization}
|
||||||
|
The PIT driver defines the command port, channel 0 port, base frequency, target frequency, divider, and tick conversion in \texttt{pit.h}. During initialization, the kernel registers an IRQ0 handler, programs the PIT command byte, and writes the divisor as low byte followed by high byte\cite{assignmentFiles,osdevPit}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void PitInitialize(void){
|
||||||
|
uint16_t divisor = DIVIDER;
|
||||||
|
|
||||||
|
RegisterInterruptHandler(IRQ0, PitIrqHandler);
|
||||||
|
|
||||||
|
OutPortByte(PIT_CMD_PORT, 0x36);
|
||||||
|
OutPortByte(PIT_CHANNEL0_PORT, (uint8_t)(divisor & 0xFF));
|
||||||
|
OutPortByte(PIT_CHANNEL0_PORT, (uint8_t)((divisor >> 8) & 0xFF));
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{PIT channel 0 initialization and IRQ0 handler registration.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
The IRQ0 handler increments a \texttt{volatile} tick counter. Keeping the counter inside \texttt{pit.c} reduces the chance of accidental writes from unrelated kernel code \cite{assignmentFiles,osdevPit}.
|
||||||
|
|
||||||
|
\subsection{Sleep Functions}
|
||||||
|
Two sleep functions were implemented. \texttt{SleepBusy()} repeatedly checks the tick counter until enough time has passed. This is simple but consumes CPU time for the whole delay.
|
||||||
|
|
||||||
|
\texttt{SleepInterrupt()} uses \texttt{sti; hlt} inside the loop. This enables interrupts and halts the CPU until the next interrupt occurs. The loop then rechecks the current tick and halts again if the requested delay has not elapsed \cite{assignmentFiles,intelSdm}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void SleepInterrupt(uint32_t ticks_to_wait){
|
||||||
|
uint32_t start_tick = GetCurrentTick();
|
||||||
|
uint32_t end_tick = start_tick + ticks_to_wait;
|
||||||
|
|
||||||
|
while(GetCurrentTick() < end_tick){
|
||||||
|
__asm__ volatile ("sti; hlt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Interrupt-based sleeping using the PIT tick counter and \texttt{hlt}.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\subsection{Verification}
|
||||||
|
Several integration issues were fixed during PIT work. Incorrect inline assembly syntax was corrected, terminal output functions replaced unsuitable \texttt{printf()} calls, and the missing \texttt{TerminalWriteHex()} declaration was added to \texttt{terminal.h}.
|
||||||
|
|
||||||
|
The build process also showed that rebuilding only \texttt{uiaos-kernel} updates \texttt{kernel.bin}, but does not update the bootable ISO image. Rebuilding \texttt{uiaos-create-image} was necessary before QEMU booted the newest kernel. Runtime testing then showed paging output, heap output, allocation output, and repeated sleep-test messages for both busy-wait and interrupt-based sleeping.
|
||||||
|
|
||||||
|
\section{PC Speaker Music Player}
|
||||||
|
|
||||||
|
Assignment 5 used the PIT and PC speaker to implement a simple music player. The PC speaker is controlled through I/O port \texttt{0x61}, while PIT channel 2 generates the square wave used for audible notes\cite{osdevPcSpeaker}.
|
||||||
|
|
||||||
|
\subsection{Sound Generation}
|
||||||
|
The speaker is enabled by setting bits 0 and 1 on port \texttt{0x61}. To play a note, the kernel calculates a divisor from the PIT base frequency and the requested note frequency, programs PIT channel 2, and then enables the speaker.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
void PlaySound(uint32_t frequency) {
|
||||||
|
if (!frequency) return;
|
||||||
|
|
||||||
|
uint32_t divisor = PIT_BASE_FREQ / frequency;
|
||||||
|
|
||||||
|
OutPortByte(PIT_CMD_PORT, 0xB6);
|
||||||
|
OutPortByte(PIT_CHANNEL2_PORT, divisor & 0xFF);
|
||||||
|
OutPortByte(PIT_CHANNEL2_PORT, (divisor >> 8) & 0xFF);
|
||||||
|
|
||||||
|
EnableSpeaker();
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Programming PIT channel 2 to generate a PC speaker tone.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Song Representation and Playback}
|
||||||
|
Songs are represented as arrays of notes. Each note stores a frequency and duration in milliseconds. A rest is represented by frequency \texttt{R}, which is defined as \texttt{0}. Playback therefore consists of selecting a frequency, sleeping for the note duration, and then stopping the speaker.
|
||||||
|
|
||||||
|
The implementation uses \texttt{SleepInterrupt()} for note timing. This replaced busy-wait delays because the interrupt-based delay gave better timing and avoided wasting CPU cycles while each note was playing.
|
||||||
|
|
||||||
|
QEMU's PC speaker support made fast note changes unclear. To make playback more distinguishable, durations were doubled during playback.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
if (currentNote.frequency == R) {
|
||||||
|
StopSound();
|
||||||
|
SleepInterrupt(currentNote.duration * 2);
|
||||||
|
} else {
|
||||||
|
PlaySound(currentNote.frequency);
|
||||||
|
SleepInterrupt(currentNote.duration * 2);
|
||||||
|
StopSound();
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Song playback using rests, PC speaker tones, and interrupt-based timing.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
227
AdvOpsys/notes/report/6.tex
Normal file
227
AdvOpsys/notes/report/6.tex
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
\section{Application Framework, Snake, and Piano}
|
||||||
|
|
||||||
|
The final stage integrated the individual OS components into a simple application framework. Instead of booting into a single test routine, the kernel now presents a terminal menu where the user can choose between the music player, Snake, and a piano application.
|
||||||
|
|
||||||
|
\subsection{Menu-Based Control Flow}
|
||||||
|
The kernel initialization path now sets up the terminal, GDT, IDT, PIT, keyboard interrupt handler, kernel memory, and paging. After that initialization, the kernel repeatedly asks for an application number and dispatches to one of the available programs. This ties the higher-level menu flow back to the keyboard, PIT, and memory-management infrastructure implemented earlier \cite{osdevPs2Controller,osdevPit,osdevMemoryAllocation}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
while (1) {
|
||||||
|
TerminalWriteString("Enter application number:\n");
|
||||||
|
TerminalWriteString("0. Play Music\n");
|
||||||
|
TerminalWriteString("1. Play Snake\n");
|
||||||
|
TerminalWriteString("2. Play Piano\n");
|
||||||
|
char input = TerminalGetChar();
|
||||||
|
|
||||||
|
switch (input) {
|
||||||
|
case '0':
|
||||||
|
TerminalClear();
|
||||||
|
PlayMusic();
|
||||||
|
TerminalClear();
|
||||||
|
break;
|
||||||
|
case '1':
|
||||||
|
TerminalClear();
|
||||||
|
PlayGame();
|
||||||
|
TerminalClear();
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
TerminalClear();
|
||||||
|
PlayPiano();
|
||||||
|
TerminalClear();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
TerminalWriteString("\nInvalid application number.\n");
|
||||||
|
SleepInterrupt(1000);
|
||||||
|
TerminalClear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Application menu dispatch in the kernel main loop.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
This demonstrates that the terminal, keyboard, interrupt, timer, memory, and application code can cooperate through a single kernel control flow.
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Snake Game State}
|
||||||
|
Snake uses a dynamically allocated game state. The game state contains the board, snake, food, score, and pseudo-random state. Creating and destroying the game therefore exercises the kernel's \texttt{malloc()} and \texttt{free()} implementation in a more realistic setting than isolated allocation tests.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
struct GameState* CreateGame(void) {
|
||||||
|
struct GameState* game = (struct GameState*)malloc(sizeof(struct GameState));
|
||||||
|
if (!game) return 0;
|
||||||
|
|
||||||
|
game->snake = (struct Snake*)malloc(sizeof(struct Snake));
|
||||||
|
if (!game->snake) {
|
||||||
|
free(game);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
game->food = (struct Food*)malloc(sizeof(struct Food));
|
||||||
|
if (!game->food) {
|
||||||
|
free(game->snake);
|
||||||
|
free(game);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
game->score = 0;
|
||||||
|
game->rngState = GetCurrentTick();
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{Snake creates its game objects using the kernel heap allocator.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Input, Timing, and Feedback}
|
||||||
|
The Snake loop reads the last key pressed by the keyboard handler, updates the snake direction, moves the snake, checks collisions, redraws the board, and then sleeps using the PIT. The game uses \texttt{GAME\_SPEED\_MS} to control pacing.
|
||||||
|
|
||||||
|
Sound is also integrated into the game. Eating food, dying, and winning each trigger short PC speaker effects. This connects the application layer back to the PIT and PC speaker support implemented earlier.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
input = GetLastKeyPressed();
|
||||||
|
HandleInput(game, input);
|
||||||
|
tail = MoveSnake(game->snake);
|
||||||
|
|
||||||
|
collisionType = CheckCollision(game->snake, game->food);
|
||||||
|
if (collisionType == FOOD) {
|
||||||
|
game->score++;
|
||||||
|
PlayFoodSound();
|
||||||
|
AddSegment(game->snake, tail.x, tail.y);
|
||||||
|
SpawnFood(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawBoard(game);
|
||||||
|
SleepInterrupt(GAME_SPEED_MS);
|
||||||
|
\end{minted}
|
||||||
|
\caption{Main Snake loop combining keyboard input, game state, sound, drawing, and PIT timing.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Piano Application State}
|
||||||
|
The piano application adds a second interactive program that uses the same core kernel services in a different way. Instead of a continuously advancing game loop, the piano keeps a dynamically allocated application state with recording flags, the current note, timing information, and a song library. This makes the application a direct integration point between the heap allocator, keyboard input, PIT timing, and PC speaker output.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
struct PianoAppState* CreatePiano(void) {
|
||||||
|
struct PianoAppState* piano =
|
||||||
|
(struct PianoAppState*)malloc(sizeof(struct PianoAppState));
|
||||||
|
if (!piano) return 0;
|
||||||
|
|
||||||
|
piano->songLibrary =
|
||||||
|
(struct SongLibrary*)malloc(sizeof(struct SongLibrary));
|
||||||
|
if (!piano->songLibrary) {
|
||||||
|
free(piano);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
piano->songLibrary->songs =
|
||||||
|
(struct Song*)malloc(sizeof(struct Song) * MAX_SONG_COUNT);
|
||||||
|
if (!piano->songLibrary->songs) {
|
||||||
|
free(piano->songLibrary);
|
||||||
|
free(piano);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{The piano allocates persistent application state and storage for recorded songs.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\subsection{Key Mapping and Sound Generation}
|
||||||
|
The piano maps keyboard characters to musical notes. White and black keys are laid out in the terminal UI, and pressing a mapped key programs PIT channel 2 with the matching frequency before enabling the PC speaker. This reuses the same low-level mechanism as the earlier music player, but now the sound generation is driven directly by live keyboard input rather than a predefined song array \cite{osdevPcSpeaker,osdevPit}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
case 'z':
|
||||||
|
PianoPlaySound(C4);
|
||||||
|
piano->activeFrequency = C4;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 's':
|
||||||
|
PianoPlaySound(Cs4);
|
||||||
|
piano->activeFrequency = Cs4;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'x':
|
||||||
|
PianoPlaySound(D4);
|
||||||
|
piano->activeFrequency = D4;
|
||||||
|
break;
|
||||||
|
\end{minted}
|
||||||
|
\caption{Keyboard input is translated into note frequencies for live piano playback.}
|
||||||
|
\end{listing}
|
||||||
|
|
||||||
|
\subsection{Recording and Playback}
|
||||||
|
The piano also adds a simple song-recording feature. When recording is enabled, played notes are stored in a song buffer together with their durations. The code also tracks the PIT tick count between notes, so pauses longer than a small threshold are stored as rests. During playback, those notes and rests are replayed through the PC speaker using \texttt{SleepInterrupt()} for timing \cite{osdevPit,osdevPcSpeaker}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
if (piano->recording && piano->lastNoteEndTick != 0) {
|
||||||
|
uint32_t now = GetCurrentTick();
|
||||||
|
uint32_t restDuration = now - piano->lastNoteEndTick;
|
||||||
|
|
||||||
|
if (restDuration > 20) {
|
||||||
|
RecordNote(piano, R, restDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PianoHandleInput(piano);
|
||||||
|
|
||||||
|
if (piano->recording) {
|
||||||
|
RecordNote(piano, piano->activeFrequency, PIANO_NOTE_DURATION);
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{The piano stores both played notes and longer gaps so recorded songs preserve rhythm.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\subsection{Interactive Piano Loop}
|
||||||
|
The runtime structure of the piano differs from Snake. Snake updates on every timer-based iteration, while the piano mostly waits for keyboard input, reacts to note and control keys, redraws the terminal UI, and uses short PIT sleeps to avoid a tight polling loop when no key has been pressed. This makes the piano a useful example of an event-driven terminal application built from the same kernel mechanisms as the rest of the project \cite{osdevPs2Controller,osdevPit}.
|
||||||
|
|
||||||
|
\begin{listing}[H]
|
||||||
|
\begin{minted}{c}
|
||||||
|
while (1) {
|
||||||
|
char input = GetLastKeyPressed();
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
SleepInterrupt(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input == 'q') {
|
||||||
|
PianoStopSound();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input == 'r' || input == 'p') {
|
||||||
|
piano->lastInput = input;
|
||||||
|
PianoHandleInput(piano);
|
||||||
|
TerminalClear();
|
||||||
|
DrawPianoUi(piano);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{minted}
|
||||||
|
\caption{The piano loop combines keyboard polling, terminal redraws, and PIT-backed waiting.}
|
||||||
|
\end{listing}
|
||||||
|
\clearpage
|
||||||
|
|
||||||
|
\section{Final State of the Project}
|
||||||
|
|
||||||
|
At the end of the documented work, the kernel contains:
|
||||||
|
\begin{itemize}
|
||||||
|
\item early GDT setup and segment register reloading,
|
||||||
|
\item IDT, ISR, IRQ, PIC, and keyboard interrupt support,
|
||||||
|
\item kernel heap initialization and simple dynamic allocation,
|
||||||
|
\item identity-mapped paging for the early kernel address space,
|
||||||
|
\item PIT-based timer ticks and sleep functions,
|
||||||
|
\item PC speaker sound generation through PIT channel 2,
|
||||||
|
\item a menu-driven application flow,
|
||||||
|
\item a music player application,
|
||||||
|
\item an interactive Snake game using memory allocation, keyboard input, timing, terminal drawing, and sound feedback,
|
||||||
|
\item an interactive piano application with live note input, song recording, and playback.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
The project therefore moved from a minimal booting kernel that printed static text into a small interactive operating-system environment with hardware interrupts, timing, memory management, terminal input, sound, and application-level control flow. The final piano addition is especially useful as an integration example because it combines dynamic allocation, live keyboard input, PIT-based timing, terminal rendering, and PC speaker output inside one self-contained application.
|
||||||
244
AdvOpsys/notes/report/bib.tex
Normal file
244
AdvOpsys/notes/report/bib.tex
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
%Referansene listet her kan bli brukt med \cite{name}
|
||||||
|
%De dukker bare opp om de refereres til minst en gang.
|
||||||
|
%OBS! om man har mer enn 3 forfattere er det mulig referansen ikke vil fungere, skriv da heller de to første og så et. al.
|
||||||
|
|
||||||
|
@misc{techtargetPOST,
|
||||||
|
author = {Robert Sheldon},
|
||||||
|
title = {{POST (Power-On Self-Test)}},
|
||||||
|
year = {2022},
|
||||||
|
month = {august},
|
||||||
|
day = {2},
|
||||||
|
howpublished = {\url{https://www.techtarget.com/whatis/definition/POST-Power-On-Self-Test}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevBootloader,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Bootloader}},
|
||||||
|
year = {2023},
|
||||||
|
month = {july},
|
||||||
|
day = {9},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Bootloader}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{wikipediaLimine,
|
||||||
|
author = {{Wikipedia contributors}},
|
||||||
|
title = {{Limine (bootloader)}},
|
||||||
|
year = {2026},
|
||||||
|
month = {april},
|
||||||
|
day = {9},
|
||||||
|
howpublished = {\url{https://en.wikipedia.org/wiki/Limine_(bootloader)}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
@misc{ionosBootloader,
|
||||||
|
author = {{IONOS}},
|
||||||
|
title = {{What is a bootloader?}},
|
||||||
|
year = {2022},
|
||||||
|
month = {november},
|
||||||
|
day = {5},
|
||||||
|
howpublished = {\url{https://www.ionos.com/digitalguide/server/configuration/what-is-a-bootloader/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{cyberraidenUEFIBoot,
|
||||||
|
author = {{Raiden}},
|
||||||
|
title = {{The Windows Operating System Boot Process in UEFI Mode}},
|
||||||
|
year = {2025},
|
||||||
|
month = {july},
|
||||||
|
day = {24},
|
||||||
|
howpublished = {\url{https://cyberraiden.wordpress.com/2025/07/24/the-windows-operating-system-boot-process-in-uefi-mode/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{wikipediaPOST,
|
||||||
|
author = {{Wikipedia}},
|
||||||
|
title = {{Power-on self-test}},
|
||||||
|
year = {2026},
|
||||||
|
month = {april},
|
||||||
|
day = {25},
|
||||||
|
howpublished = {\url{https://en.wikipedia.org/wiki/Power-on_self-test}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{littleosbook,
|
||||||
|
author = {Erik Helin and Adam Renberg},
|
||||||
|
title = {The Little Book About OS Development},
|
||||||
|
year = {2015},
|
||||||
|
howpublished = {\url{https://littleosbook.github.io/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{manybutfinite,
|
||||||
|
author = {Gustavo Duarte},
|
||||||
|
title = {How Computers Boot Up},
|
||||||
|
year = {2011},
|
||||||
|
howpublished = {\url{https://manybutfinite.com/post/how-computers-boot-up/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevInterruptsTutorial,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Interrupts Tutorial}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Interrupts_Tutorial}},
|
||||||
|
note = {Accessed 2026-04-16}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{fenollosaOsTutorial,
|
||||||
|
author = {Carlos Fenollosa},
|
||||||
|
title = {{os-tutorial}},
|
||||||
|
howpublished = {\url{https://github.com/cfenollosa/os-tutorial/tree/master}},
|
||||||
|
note = {Accessed 2026-04-16}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{assignmentFiles,
|
||||||
|
author = {{Turgay Celik}},
|
||||||
|
title = {{assignment\_files.zip}},
|
||||||
|
note = {Source code and assignment material provided by course lecturer}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevBootSequence,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Boot Sequence}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Boot_Sequence}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevMemoryMapX86,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Memory Map (x86)}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Memory_Map_(x86)}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevGdtTutorial,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{GDT Tutorial}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/GDT_Tutorial}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdev8259Pic,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{8259 PIC}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/8259_PIC}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPs2Controller,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{I8042 PS/2 Controller}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/I8042_PS/2_Controller}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevMemoryAllocation,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Memory Allocation}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Memory_Allocation}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevSettingUpPaging,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Setting Up Paging}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Setting_Up_Paging}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPit,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Programmable Interval Timer}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Programmable_Interval_Timer}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPcSpeaker,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{PC Speaker}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/PC_Speaker}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevInterrupts,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Interrupts}},
|
||||||
|
year = {2026},
|
||||||
|
month = {march},
|
||||||
|
day = {17},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Interrupts}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{uefiSpecifications,
|
||||||
|
author = {{UEFI Forum}},
|
||||||
|
title = {{UEFI Specifications}},
|
||||||
|
howpublished = {\url{https://uefi.org/specifications}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{limineProtocol,
|
||||||
|
author = {{Limine Bootloader Project}},
|
||||||
|
title = {{Limine Boot Protocol}},
|
||||||
|
howpublished = {\url{https://github.com/limine-bootloader/limine-protocol}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{linuxX86BootProtocol,
|
||||||
|
author = {{Linux Kernel Documentation}},
|
||||||
|
title = {{The Linux/x86 Boot Protocol}},
|
||||||
|
howpublished = {\url{https://docs.kernel.org/arch/x86/boot.html}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{microsoftWindowsBootOptions,
|
||||||
|
author = {{Microsoft}},
|
||||||
|
title = {{Configure and edit boot options in Windows for driver development}},
|
||||||
|
howpublished = {\url{https://learn.microsoft.com/en-us/windows-hardware/drivers/devtest/boot-options-in-windows}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{appleSiliconBootProcess,
|
||||||
|
author = {{Apple}},
|
||||||
|
title = {{Boot process for a Mac with Apple silicon}},
|
||||||
|
howpublished = {\url{https://support.apple.com/guide/security/secac71d5623/web}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{appleIntelBootProcess,
|
||||||
|
author = {{Apple}},
|
||||||
|
title = {{Boot process for an Intel-based Mac}},
|
||||||
|
howpublished = {\url{https://support.apple.com/guide/security/sec5d0fab7c6/web}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{qemuPcMachine,
|
||||||
|
author = {{QEMU Project}},
|
||||||
|
title = {{i440fx PC (pc-i440fx, pc)}},
|
||||||
|
howpublished = {\url{https://qemu.readthedocs.io/en/v8.1.5/system/i386/pc.html}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{hashnodeVMboot,
|
||||||
|
author = {{Logeshwaran N}},
|
||||||
|
title = {{Virtual Machine Boot Process Explained – Easy Guide}},
|
||||||
|
year = {2024},
|
||||||
|
month = {october},
|
||||||
|
day = {12},
|
||||||
|
howpublished = {\url{https://logeshwrites.hashnode.dev/virtual-machine-boot-process-explained-easy-guide}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{intelSdm,
|
||||||
|
author = {{Intel}},
|
||||||
|
title = {{Intel 64 and IA-32 Architectures Software Developer's Manual}},
|
||||||
|
howpublished = {\url{https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html}},
|
||||||
|
note = {Accessed 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPrintingToScreen,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Printing To Screen}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Printing_To_Screen}},
|
||||||
|
note = {Accessed 2026-04-28}
|
||||||
|
}
|
||||||
100
AdvOpsys/notes/report/boot_topics.tex
Normal file
100
AdvOpsys/notes/report/boot_topics.tex
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
\section{Boot Process Background}
|
||||||
|
|
||||||
|
This section gives background context for the boot process before the kernel-specific implementation described in the rest of the report. It covers the firmware stages, bootloader responsibilities, memory layout, and the effect of virtualization on booting.
|
||||||
|
|
||||||
|
\subsection{Power-On Self-Test}
|
||||||
|
When a computer is powered on or reset, the processor begins execution from a firmware-defined reset vector. At this point, the operating system is not running yet. The first responsibility belongs to the system firmware, either BIOS on older x86 systems or UEFI on newer systems \cite{osdevBootSequence,uefiSpecifications}.
|
||||||
|
|
||||||
|
One of the earliest firmware tasks is the Power-On Self-Test, usually shortened to POST. POST checks that the basic hardware needed to continue booting is present and responding. This includes components such as:
|
||||||
|
\begin{itemize}
|
||||||
|
\item the CPU,
|
||||||
|
\item system memory,
|
||||||
|
\item firmware storage,
|
||||||
|
\item video output,
|
||||||
|
\item keyboard or input controllers,
|
||||||
|
\item storage controllers and bootable devices.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
POST is important because later boot stages assume that the basic machine state is usable. If a required device fails or memory cannot be initialized, the firmware may stop the boot process and report the failure through screen output, status LEDs, or beep codes.
|
||||||
|
|
||||||
|
The interaction between POST and hardware is therefore direct and low-level. Firmware initializes chipset state, configures memory controllers, discovers attached devices, and prepares enough of the platform that a bootloader can be found and executed. This is different from normal operating-system hardware management, because POST happens before the OS has loaded drivers or enabled its own abstractions.
|
||||||
|
|
||||||
|
\subsection{Boot Sequence After POST}
|
||||||
|
After a successful POST, the firmware chooses a boot device according to its configured boot order. On BIOS systems, this usually means reading the first sector of a bootable disk into memory and jumping to it. On UEFI systems, the firmware instead loads an EFI application from a filesystem on the EFI System Partition \cite{osdevBootSequence,uefiSpecifications}.
|
||||||
|
|
||||||
|
The high-level sequence after POST is:
|
||||||
|
\begin{enumerate}
|
||||||
|
\item firmware completes hardware initialization,
|
||||||
|
\item firmware selects a boot device,
|
||||||
|
\item the first bootloader stage or EFI boot application is loaded,
|
||||||
|
\item the bootloader prepares the environment expected by the kernel,
|
||||||
|
\item the kernel image is loaded into memory,
|
||||||
|
\item control is transferred to the kernel entry point.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
In a BIOS boot flow, the first loaded code is small because the initial boot sector is limited to 512 bytes. This usually forces the bootloader to use multiple stages. The first stage is responsible for loading a larger second stage, and the second stage can then parse filesystems, load the kernel, and prepare boot information \cite{osdevBootSequence}.
|
||||||
|
|
||||||
|
In a UEFI boot flow, the firmware provides more services before the operating system starts. A UEFI bootloader can use firmware-provided filesystem and device services, and it is loaded as a normal executable rather than as raw code from a fixed disk sector. This makes UEFI bootloaders more flexible, but the kernel must still eventually take control and stop depending on firmware runtime assumptions.
|
||||||
|
|
||||||
|
\subsection{Bootloaders}
|
||||||
|
A bootloader is the bridge between firmware and the operating-system kernel. Its purpose is not only to start the kernel, but also to place the machine into a state the kernel understands.
|
||||||
|
|
||||||
|
Common bootloader responsibilities include:
|
||||||
|
\begin{itemize}
|
||||||
|
\item locating and loading the kernel image,
|
||||||
|
\item loading additional modules or initrd files,
|
||||||
|
\item obtaining a memory map from the firmware,
|
||||||
|
\item selecting or setting a graphics mode,
|
||||||
|
\item preparing boot information structures,
|
||||||
|
\item switching CPU mode when required,
|
||||||
|
\item transferring control to the kernel entry point.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Different bootloaders provide different levels of support. GRUB is a general-purpose bootloader with filesystem support, configuration files, multiboot support, and broad hardware compatibility. Limine is a modern boot protocol and bootloader often used in hobby OS development because it provides a clear boot protocol and supports both BIOS and UEFI systems \cite{osdevBootSequence,limineProtocol}. A manually implemented bootloader gives full control over the boot path, but requires more work and exposes the project to more early-stage hardware and filesystem details.
|
||||||
|
|
||||||
|
The choice of bootloader depends on the goals of the operating-system project. For a kernel-focused project, using an existing bootloader is usually the most practical choice because it avoids spending most of the work on disk loading, filesystem parsing, firmware differences, and CPU mode transitions. For a project specifically about bootstrapping, implementing a bootloader manually can be useful because it exposes the details normally hidden by existing tools.
|
||||||
|
|
||||||
|
Manual bootloader implementation is challenging because the environment is very limited. In a BIOS first-stage bootloader, code size is constrained, there is no standard library, only a small amount of state is initialized, and disk access must use firmware interrupts or direct hardware access. The bootloader must also be careful about where it places itself, the kernel, stacks, tables, and temporary buffers in memory.
|
||||||
|
|
||||||
|
\subsection{Memory Layout in the Boot Process}
|
||||||
|
In a traditional i386 BIOS boot process, early memory layout is constrained by legacy conventions. The first MiB of memory contains several important regions, including the interrupt vector table, BIOS data area, conventional memory, video memory, and firmware regions \cite{osdevMemoryMapX86}.
|
||||||
|
|
||||||
|
The bootloader and kernel must avoid overwriting memory that is already used by firmware, hardware mappings, or the bootloader itself. In small BIOS examples, a common simple kernel load address is \texttt{0x10000}. This address is above the lowest BIOS data structures and gives the kernel more room than the original boot sector area, while still being within conventional memory that is easy to access in early boot stages. In this project, the linker script places the kernel at \texttt{1 MiB}, which is also a common protected-mode kernel load address and keeps the kernel away from the lowest legacy BIOS regions.
|
||||||
|
|
||||||
|
Choosing a fixed early kernel load address has practical implications:
|
||||||
|
\begin{itemize}
|
||||||
|
\item the bootloader needs to copy or load the kernel to a known address,
|
||||||
|
\item the kernel linker script must match the address where the kernel expects to run,
|
||||||
|
\item early stacks and temporary buffers must be placed so they do not overlap the kernel,
|
||||||
|
\item later memory management must identify which regions are already occupied.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Once the kernel has control, it can replace this simple early memory model with its own memory management. In this project, that later stage is represented by kernel heap initialization, page-aligned allocation, and paging setup.
|
||||||
|
|
||||||
|
\subsection{Boot Process in Modern Operating Systems}
|
||||||
|
Modern operating systems use more complex boot processes than small teaching kernels, but the same basic idea remains: firmware initializes the platform, a bootloader or boot manager loads the kernel, and the kernel takes control of the machine.
|
||||||
|
|
||||||
|
Linux systems commonly use firmware to start a bootloader such as GRUB or systemd-boot. The bootloader loads the kernel and an initramfs, passes a command line and boot information, and then transfers control to the Linux kernel. Windows systems use Windows Boot Manager, which loads the Windows OS loader before the kernel and core system components are started. macOS uses Apple's boot chain, which is tightly integrated with Apple hardware, APFS, Secure Boot policies, and system volume verification \cite{linuxX86BootProtocol,microsoftWindowsBootOptions,appleSiliconBootProcess,appleIntelBootProcess}.
|
||||||
|
|
||||||
|
Modern boot processes have changed because hardware and security requirements have changed. UEFI replaced many BIOS conventions, disks moved from MBR partitioning toward GPT, and Secure Boot introduced signature verification into the boot chain. Storage devices are also more complex than older BIOS-era disks, and modern systems often need early support for NVMe, encryption, graphics initialization, and platform security features.
|
||||||
|
|
||||||
|
The result is that modern booting is both more capable and more controlled. Firmware and bootloaders provide richer services, but the operating system must also participate in a stricter trust chain and handle more platform variation.
|
||||||
|
|
||||||
|
\subsection{Virtual Machines and Booting}
|
||||||
|
Booting inside a virtual machine follows the same conceptual stages as booting on physical hardware, but the hardware being initialized is virtual. The guest operating system still sees firmware, CPU state, memory, storage devices, timers, and interrupt controllers. However, those devices are provided or emulated by the hypervisor \cite{qemuPcMachine}.
|
||||||
|
|
||||||
|
The main difference is that a virtual machine does not start from real motherboard hardware. Instead, the hypervisor creates a virtual hardware environment and then starts the guest firmware inside it. The guest firmware performs a boot sequence that looks normal from inside the VM, but many device operations are handled by the hypervisor on the host.
|
||||||
|
|
||||||
|
The hypervisor has several roles in this process:
|
||||||
|
\begin{itemize}
|
||||||
|
\item allocating guest memory,
|
||||||
|
\item exposing virtual CPUs,
|
||||||
|
\item providing virtual disks and network devices,
|
||||||
|
\item emulating or virtualizing interrupt controllers and timers,
|
||||||
|
\item presenting BIOS or UEFI firmware to the guest,
|
||||||
|
\item handling privileged operations that cannot run directly on the host CPU.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
Virtualized booting has advantages for operating-system development. It is faster to test than rebooting physical hardware, the virtual machine can be reset easily, debugging is easier with tools such as QEMU and GDB, and hardware behaviour is more reproducible. This is why the kernel in this project was tested through QEMU rather than directly on physical hardware.
|
||||||
|
|
||||||
|
There are also limitations. Emulated hardware may not behave exactly like real hardware, and some devices are simplified compared to physical machines. For example, PC speaker output in QEMU can be limited, which affected the clarity of fast note changes in the music player. Even so, virtualization is highly useful for kernel development because it provides a controlled environment for testing boot, interrupts, memory management, and device interaction \cite{qemuPcMachine}.
|
||||||
14
AdvOpsys/notes/report/conclusion.tex
Normal file
14
AdvOpsys/notes/report/conclusion.tex
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
\section{Conclusion}
|
||||||
|
|
||||||
|
The project started with a minimal bootable kernel and developed it into a small interactive operating-system environment. The final kernel includes early processor setup, interrupt handling, memory management, PIT-based timing, PC speaker sound, keyboard input, terminal output, a menu system, a music player, and a Snake game.
|
||||||
|
|
||||||
|
The first major step was to make the processor setup explicit by adding a minimal Global Descriptor Table and reloading the segment registers correctly. This provided a controlled protected-mode foundation for later work. Interrupt handling then extended the kernel from linear startup code into an event-driven system, with CPU exceptions, hardware IRQs, PIC remapping, and keyboard input.
|
||||||
|
|
||||||
|
Memory management and paging added another important layer. The kernel gained heap initialization, dynamic allocation, page-aligned allocation, memory utility functions, and identity-mapped paging. These features made it possible for later code to allocate state dynamically rather than relying only on static data.
|
||||||
|
|
||||||
|
The PIT then provided a timing source through IRQ0. This supported both busy-wait and interrupt-based sleeping, with the interrupt-based version becoming important for later features. The same timing infrastructure was reused by the music player and Snake game. The PC speaker work showed how PIT channel 2 could be used for sound generation, while also showing the practical limits of testing audio through QEMU.
|
||||||
|
|
||||||
|
The final application framework tied the lower-level pieces together. The menu system used terminal output and keyboard input to choose between applications. The music player used PIT timing and PC speaker output. Snake combined memory allocation, keyboard input, PIT-based pacing, terminal drawing, and sound effects. This made the final application stage a practical test of whether the kernel subsystems could work together.
|
||||||
|
|
||||||
|
Overall, the project demonstrates how small operating-system mechanisms build on each other. GDT setup makes the early CPU state reliable, interrupts make asynchronous hardware events usable, memory management allows dynamic state, timing enables delays and pacing, and device output makes applications interactive. The result is not a complete general-purpose operating system, but it is a working kernel environment that shows the relationship between low-level hardware control and higher-level program behaviour.
|
||||||
|
|
||||||
11
AdvOpsys/notes/report/intro.tex
Normal file
11
AdvOpsys/notes/report/intro.tex
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
\section{Introduction}
|
||||||
|
|
||||||
|
This report documents both the boot-process background behind operating-system startup and the development of a small 32-bit operating-system kernel for the \texttt{Advanced Operatingsystems} project. It starts by explaining the firmware and bootloader stages that happen before a kernel runs, then follows the project implementation from a minimal bootable kernel into a more interactive environment with processor setup, interrupts, memory management, timing, sound, keyboard input, and simple applications.
|
||||||
|
|
||||||
|
The background section covers the general boot sequence: Power-On Self-Test, firmware handoff, BIOS and UEFI differences, bootloader responsibilities, early i386 memory layout, modern operating-system boot chains, and the role of virtualization. This gives context for why the project can rely on a bootloader, why the kernel starts from a constrained early machine state, and why QEMU is useful for testing this kind of low-level work.
|
||||||
|
|
||||||
|
The implementation itself is intentionally low-level. Most of the kernel interacts directly with x86 processor structures and hardware interfaces, including the Global Descriptor Table, Interrupt Descriptor Table, Programmable Interrupt Controller, Programmable Interval Timer, VGA text output, keyboard controller, and PC speaker. Because the kernel runs in a freestanding environment, basic facilities that are normally provided by an operating system or standard library also had to be implemented inside the project.
|
||||||
|
|
||||||
|
After the boot background, the report follows the same order as the implementation work. The first implementation part covers the early boot path and processor setup, with particular focus on the Global Descriptor Table and the need to reload segment registers after installing it. The second part describes interrupt handling, including CPU exceptions, hardware IRQs, PIC remapping, and keyboard input. The third part introduces memory management, paging, PIT-based timing, sleep functions, and PC speaker music playback. The final part shows how these individual subsystems were combined into a small menu-driven application environment with a music player, an interactive Snake game and a piano application.
|
||||||
|
|
||||||
|
The overall goal was not to build a complete general-purpose operating system, but to demonstrate how core operating-system mechanisms fit together. Each stage adds a specific capability, and later stages reuse earlier ones: the PIT depends on interrupt handling, the music player depends on PIT timing and PC speaker output, Snake depends on memory allocation, keyboard input, terminal drawing, timing, and sound feedback, and Piano app depends on keyboard input, terminal drawing, PIT timing and memory allocation. By the end of the project, the kernel has moved from static startup output to an event-driven system that can run simple interactive applications.
|
||||||
41
AdvOpsys/notes/report/problems.tex
Normal file
41
AdvOpsys/notes/report/problems.tex
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
\section{Problems and Challenges}
|
||||||
|
|
||||||
|
Several issues appeared during the project because the kernel runs in a freestanding environment where there is little separation between build configuration, CPU state, hardware programming, and application behaviour. Most problems were not isolated to one source file; they came from interactions between the boot image, low-level initialization, interrupts, timing, and terminal output.
|
||||||
|
|
||||||
|
\subsection{Processor State and Verification}
|
||||||
|
The GDT implementation required careful verification because a mistake in this stage can prevent the kernel from continuing at all. One important detail was that loading the GDT with \texttt{lgdt} does not automatically update the cached segment registers. The implementation therefore needed a far jump to reload \texttt{cs}, followed by explicit reloads of \texttt{ds}, \texttt{es}, \texttt{fs}, \texttt{gs}, and \texttt{ss}.
|
||||||
|
|
||||||
|
This was verified both statically and at runtime. Static inspection confirmed that the kernel binary contained the expected GDT load sequence, far jump, and data segment reloads. Runtime debugging in QEMU with \texttt{gdb-multiarch} confirmed that \texttt{cs} became \texttt{0x08} and that the data segment registers became \texttt{0x10}. This was necessary because the code could compile correctly while still failing if the processor did not actually enter the expected segment state.
|
||||||
|
|
||||||
|
\subsection{Build and Boot Image Mismatch}
|
||||||
|
A major integration problem appeared during the PIT work. Rebuilding only the kernel target updated \texttt{kernel.bin}, but did not update the bootable ISO image. As a result, QEMU could still boot an older image even after the source code and kernel binary had changed.
|
||||||
|
|
||||||
|
This made the runtime output misleading. The newer \texttt{kernel.bin} contained PIT and memory-management strings, while the older \texttt{kernel.iso} still showed the earlier \texttt{Hello, World!} output. The issue was resolved by rebuilding the bootable image target so the ISO contained the updated kernel.
|
||||||
|
|
||||||
|
This problem showed that kernel development requires verifying not only the source code, but also the exact artifact being booted. When the build system produces multiple outputs, it is possible to test the wrong one without noticing immediately.
|
||||||
|
|
||||||
|
\subsection{Freestanding C and Header Consistency}
|
||||||
|
Because the kernel does not run with a normal hosted C environment, ordinary library assumptions do not always apply. During the PIT integration, \texttt{printf()} calls had to be removed from test code and replaced with the kernel's terminal output functions. The terminal interface also needed to expose \texttt{TerminalWriteHex()} through the header because the function was implemented in \texttt{terminal.c} but not declared for other modules.
|
||||||
|
|
||||||
|
Inline assembly syntax also caused a build issue. An incorrect form of \texttt{\_\_asm\_\_volatile} had to be corrected to \texttt{\_\_asm\_\_ volatile}. This was a small syntax problem, but in low-level code it blocked the PIT sleep implementation because \texttt{sti; hlt} was part of the interrupt-based delay loop.
|
||||||
|
|
||||||
|
\subsection{Interrupt Dependencies}
|
||||||
|
Several later features depended on interrupt handling being correct. The PIT relies on IRQ0, keyboard input relies on IRQ1, and both the music player and Snake depend on timer and keyboard behaviour. This meant that interrupt setup was not only an isolated assignment task; it became part of the foundation for the rest of the project.
|
||||||
|
|
||||||
|
The PIC also had to be remapped so hardware IRQs did not overlap with CPU exception vectors. Without this separation, hardware events and processor exceptions would be harder to distinguish, and debugging later features would become much less reliable.
|
||||||
|
|
||||||
|
\subsection{Terminal Output Limitations}
|
||||||
|
The terminal output implementation was sufficient for early debugging and application display, but repeated output exposed a limitation. During the PIT sleep test, once the VGA text buffer wrapped back to the top of the screen, new text overwrote old text directly. This made long-running debug output harder to read.
|
||||||
|
|
||||||
|
The later application framework reduced this issue by clearing the terminal before drawing menus and game frames. However, the underlying limitation remains: the terminal would benefit from proper scrolling or a cleaner screen-management model for continuous logs.
|
||||||
|
|
||||||
|
\subsection{PC Speaker and QEMU Limitations}
|
||||||
|
The music player used the PC speaker and PIT channel 2 to generate square wave tones. The implementation worked, but QEMU's PC speaker support limited the clarity of playback. Fast note changes could sound unclear, and songs degraded when played at the original speed.
|
||||||
|
|
||||||
|
The practical adjustment was to double note durations during playback. This slowed down the music and made individual notes easier to distinguish. The limitation was therefore not mainly in the note representation or PIT timing logic, but in the quality and behaviour of the emulated sound output.
|
||||||
|
|
||||||
|
\subsection{Subsystem Integration}
|
||||||
|
The final Snake application exposed the main integration challenge of the project. Snake uses dynamic memory allocation for game state, keyboard interrupts for input, PIT timing for movement speed, terminal output for drawing, and PC speaker output for feedback. A problem in any of these lower-level systems can appear as an application-level bug.
|
||||||
|
|
||||||
|
This made the final stage useful as more than a demonstration. It acted as an integration test for the kernel. The menu and Snake game showed that the separate subsystems could cooperate through a single control flow, but they also made clear that kernel features must be built in a stable order: processor setup first, then interrupts, then memory and timing, then application behaviour.
|
||||||
|
|
||||||
220
AdvOpsys/notes/report/references.tex
Normal file
220
AdvOpsys/notes/report/references.tex
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
%Referansene listet her kan bli brukt med \cite{name}
|
||||||
|
%De dukker bare opp om de refereres til minst en gang.
|
||||||
|
%OBS! om man har mer enn 3 forfattere er det mulig referansen ikke vil fungere, skriv da heller de to første og så et. al.
|
||||||
|
|
||||||
|
@misc{techtargetPOST,
|
||||||
|
author = {Robert Sheldon},
|
||||||
|
title = {{POST (Power-On Self-Test)}},
|
||||||
|
year = {2022},
|
||||||
|
month = {august},
|
||||||
|
day = {2},
|
||||||
|
howpublished = {\url{https://www.techtarget.com/whatis/definition/POST-Power-On-Self-Test}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevBootloader,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Bootloader}},
|
||||||
|
year = {2023},
|
||||||
|
month = {july},
|
||||||
|
day = {9},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Bootloader}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{wikipediaLimine,
|
||||||
|
author = {{Wikipedia contributors}},
|
||||||
|
title = {{Limine (bootloader)}},
|
||||||
|
year = {2026},
|
||||||
|
month = {april},
|
||||||
|
day = {9},
|
||||||
|
howpublished = {\url{https://en.wikipedia.org/wiki/Limine_(bootloader)}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
@misc{ionosBootloader,
|
||||||
|
author = {{IONOS}},
|
||||||
|
title = {{What is a bootloader?}},
|
||||||
|
year = {2022},
|
||||||
|
month = {november},
|
||||||
|
day = {5},
|
||||||
|
howpublished = {\url{https://www.ionos.com/digitalguide/server/configuration/what-is-a-bootloader/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{cyberraidenUEFIBoot,
|
||||||
|
author = {{Raiden}},
|
||||||
|
title = {{The Windows Operating System Boot Process in UEFI Mode}},
|
||||||
|
year = {2025},
|
||||||
|
month = {july},
|
||||||
|
day = {24},
|
||||||
|
howpublished = {\url{https://cyberraiden.wordpress.com/2025/07/24/the-windows-operating-system-boot-process-in-uefi-mode/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{wikipediaPOST,
|
||||||
|
author = {{Wikipedia}},
|
||||||
|
title = {{Power-on self-test}},
|
||||||
|
year = {2026},
|
||||||
|
month = {april},
|
||||||
|
day = {25},
|
||||||
|
howpublished = {\url{https://en.wikipedia.org/wiki/Power-on_self-test}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{littleosbook,
|
||||||
|
author = {Erik Helin and Adam Renberg},
|
||||||
|
title = {The Little Book About OS Development},
|
||||||
|
year = {2015},
|
||||||
|
howpublished = {\url{https://littleosbook.github.io/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{manybutfinite,
|
||||||
|
author = {Gustavo Duarte},
|
||||||
|
title = {How Computers Boot Up},
|
||||||
|
year = {2011},
|
||||||
|
howpublished = {\url{https://manybutfinite.com/post/how-computers-boot-up/}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevInterruptsTutorial,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Interrupts Tutorial}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Interrupts_Tutorial}},
|
||||||
|
note = {Accessed 2026-04-16}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{fenollosaOsTutorial,
|
||||||
|
author = {Carlos Fenollosa},
|
||||||
|
title = {{os-tutorial}},
|
||||||
|
howpublished = {\url{https://github.com/cfenollosa/os-tutorial/tree/master}},
|
||||||
|
note = {Accessed 2026-04-16}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{assignmentFiles,
|
||||||
|
author = {{Turgay Celik}},
|
||||||
|
title = {{assignment\_files.zip}},
|
||||||
|
note = {Source code and assignment material provided by course lecturer}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevBootSequence,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Boot Sequence}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Boot_Sequence}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevMemoryMapX86,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Memory Map (x86)}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Memory_Map_(x86)}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevGdtTutorial,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{GDT Tutorial}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/GDT_Tutorial}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdev8259Pic,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{8259 PIC}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/8259_PIC}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPs2Controller,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{I8042 PS/2 Controller}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/I8042_PS/2_Controller}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevMemoryAllocation,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Memory Allocation}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Memory_Allocation}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevSettingUpPaging,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Setting Up Paging}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Setting_Up_Paging}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPit,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{Programmable Interval Timer}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/Programmable_Interval_Timer}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{osdevPcSpeaker,
|
||||||
|
author = {{OSDev Wiki}},
|
||||||
|
title = {{PC Speaker}},
|
||||||
|
howpublished = {\url{https://wiki.osdev.org/PC_Speaker}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{uefiSpecifications,
|
||||||
|
author = {{UEFI Forum}},
|
||||||
|
title = {{UEFI Specifications}},
|
||||||
|
howpublished = {\url{https://uefi.org/specifications}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{limineProtocol,
|
||||||
|
author = {{Limine Bootloader Project}},
|
||||||
|
title = {{Limine Boot Protocol}},
|
||||||
|
howpublished = {\url{https://github.com/limine-bootloader/limine-protocol}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{linuxX86BootProtocol,
|
||||||
|
author = {{Linux Kernel Documentation}},
|
||||||
|
title = {{The Linux/x86 Boot Protocol}},
|
||||||
|
howpublished = {\url{https://docs.kernel.org/arch/x86/boot.html}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{microsoftWindowsBootOptions,
|
||||||
|
author = {{Microsoft}},
|
||||||
|
title = {{Configure and edit boot options in Windows for driver development}},
|
||||||
|
howpublished = {\url{https://learn.microsoft.com/en-us/windows-hardware/drivers/devtest/boot-options-in-windows}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{appleSiliconBootProcess,
|
||||||
|
author = {{Apple}},
|
||||||
|
title = {{Boot process for a Mac with Apple silicon}},
|
||||||
|
howpublished = {\url{https://support.apple.com/guide/security/secac71d5623/web}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{appleIntelBootProcess,
|
||||||
|
author = {{Apple}},
|
||||||
|
title = {{Boot process for an Intel-based Mac}},
|
||||||
|
howpublished = {\url{https://support.apple.com/guide/security/sec5d0fab7c6/web}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{qemuPcMachine,
|
||||||
|
author = {{QEMU Project}},
|
||||||
|
title = {{i440fx PC (pc-i440fx, pc)}},
|
||||||
|
howpublished = {\url{https://qemu.readthedocs.io/en/v8.1.5/system/i386/pc.html}},
|
||||||
|
note = {Accessed 2026-04-20}
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{hashnodeVMboot,
|
||||||
|
author = {{Logeshwaran N}},
|
||||||
|
title = {{Virtual Machine Boot Process Explained – Easy Guide}},
|
||||||
|
year = {2024},
|
||||||
|
month = {october},
|
||||||
|
day = {12},
|
||||||
|
howpublished = {\url{https://logeshwrites.hashnode.dev/virtual-machine-boot-process-explained-easy-guide}},
|
||||||
|
note = {Accessed: 2026-04-28}
|
||||||
|
}
|
||||||
9
AdvOpsys/notes/report/work_distribution.tex
Normal file
9
AdvOpsys/notes/report/work_distribution.tex
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
\section{Work Distribution}
|
||||||
|
|
||||||
|
The project work was divided primarily by assignment stage. Teodor was mainly responsible for assignments 2, 4, and 5, and shared assignment 6. I was mainly responsible for assignments 1 and 3, and also shared assignment 6. If only the implementation assignments are considered, this gives an approximate 60/40 split in Teodor's favour.
|
||||||
|
|
||||||
|
The report writing was distributed somewhat differently. I wrote approximately 60\% of the report text, which balanced much of the earlier implementation difference. Taken together, the implementation work and the report writing therefore gave a contribution split that was close to 50/50 overall, which we consider a reasonable balance for a group project of this type.
|
||||||
|
|
||||||
|
We also worked deliberately to maximize learning from the parts of the project that each of us had not implemented directly. After each work session, we wrote short notes describing what had been done, what design choices had been made, and what problems had appeared. This made it possible for the other person to read the notes afterward and then write that part into the report with a clear understanding of both the implementation and the reasoning behind it.
|
||||||
|
|
||||||
|
In practice, this meant that when Teodor implemented an assignment task, I would usually write the corresponding report section, and the same principle also applied in the opposite direction. This gave both of us a reason to study and explain the other person's work instead of only documenting our own. In that way, the report became part of the learning process rather than a duplicate writing pass over the same task.
|
||||||
101
AdvOpsys/notes/work-summary-2026-04-10.md
Normal file
101
AdvOpsys/notes/work-summary-2026-04-10.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Summary of Work Done on April 10, 2026
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Today, work was done on the `OSDev_18` project to improve the kernel startup sequence and to make sure the kernel still boots correctly and prints output to the screen. The main focus was to add a minimal Global Descriptor Table (GDT) setup for the i386 version of the kernel and verify that it works as expected.
|
||||||
|
|
||||||
|
The project is set up to use a dev container, so the build and test steps were carried out in that environment. This was necessary because the container already includes the required cross-compilation and debugging tools.
|
||||||
|
|
||||||
|
## GDT Implementation
|
||||||
|
|
||||||
|
A minimal GDT was added for 32-bit protected mode. The implementation uses three entries:
|
||||||
|
|
||||||
|
1. A null descriptor
|
||||||
|
2. A kernel code segment descriptor
|
||||||
|
3. A kernel data segment descriptor
|
||||||
|
|
||||||
|
The code and data segments were configured with:
|
||||||
|
|
||||||
|
- base address `0x00000000`
|
||||||
|
- effective 4 GiB address space
|
||||||
|
- 4 KiB granularity
|
||||||
|
- ring 0 privilege level
|
||||||
|
|
||||||
|
The GDT setup code builds these descriptors in memory and prepares a GDT descriptor that can be loaded by the CPU.
|
||||||
|
|
||||||
|
After that, an assembly routine loads the new GDT with `lgdt`, performs a far jump to reload the code segment register, and then reloads the remaining segment registers for data access. This step is important because loading the GDT alone does not automatically update the segment registers already cached by the processor.
|
||||||
|
|
||||||
|
## Kernel Startup Changes
|
||||||
|
|
||||||
|
The kernel startup code was updated so that the GDT is initialized before the terminal is set up. After the GDT setup is finished, the terminal initialization still runs and the kernel prints `Hello, World!` to the VGA text buffer.
|
||||||
|
|
||||||
|
This means the boot flow now works roughly like this:
|
||||||
|
|
||||||
|
1. The bootloader transfers control to the kernel entry point.
|
||||||
|
2. The kernel starts execution in C code.
|
||||||
|
3. The GDT is initialized and loaded.
|
||||||
|
4. Segment registers are reloaded.
|
||||||
|
5. The VGA terminal is initialized.
|
||||||
|
6. `Hello, World!` is printed.
|
||||||
|
7. The kernel enters an infinite halt loop.
|
||||||
|
|
||||||
|
## Build System Changes
|
||||||
|
|
||||||
|
The build configuration was updated so the kernel now includes both the new C source for the GDT logic and the new assembly source for the reload routine. This ensures that the GDT implementation is compiled and linked into the kernel binary together with the rest of the startup code.
|
||||||
|
|
||||||
|
New build artifacts were generated today, including an updated kernel binary and bootable ISO image.
|
||||||
|
|
||||||
|
## Documentation Work
|
||||||
|
|
||||||
|
The project documentation was also improved today. Short explanations were added to describe:
|
||||||
|
|
||||||
|
- the general kernel boot flow
|
||||||
|
- where the GDT setup happens
|
||||||
|
- why `lgdt` is not enough on its own
|
||||||
|
- why a far jump and segment-register reload are required after loading the GDT
|
||||||
|
|
||||||
|
This was done to make the code easier to follow and to connect the implementation more clearly to the assignment requirements.
|
||||||
|
|
||||||
|
## Verification and Testing
|
||||||
|
|
||||||
|
The GDT implementation was tested in two ways.
|
||||||
|
|
||||||
|
### 1. Static inspection of the kernel binary
|
||||||
|
|
||||||
|
The built kernel binary was inspected to confirm that the expected GDT-related symbols were present and that the generated machine code actually contained:
|
||||||
|
|
||||||
|
- a call to load the GDT
|
||||||
|
- a far jump to selector `0x08`
|
||||||
|
- reloads of the data segment registers using selector `0x10`
|
||||||
|
|
||||||
|
This showed that the expected GDT setup sequence had been compiled into the kernel.
|
||||||
|
|
||||||
|
### 2. Runtime debugging in QEMU with GDB
|
||||||
|
|
||||||
|
The kernel was then started in QEMU and examined with `gdb-multiarch`. During this session, execution was stopped inside the GDT initialization code and inside the assembly reload routine.
|
||||||
|
|
||||||
|
The following results were observed:
|
||||||
|
|
||||||
|
- the GDT descriptor size was `0x17`, which matches three 8-byte entries minus 1
|
||||||
|
- the code segment entry was populated with the expected values
|
||||||
|
- after the far jump, the `cs` register changed to `0x08`
|
||||||
|
- after stepping through the segment reload instructions, `ds`, `es`, `fs`, `gs`, and `ss` all became `0x10`
|
||||||
|
|
||||||
|
These checks show that the processor did not just compile the code correctly, but also used the new GDT correctly at runtime.
|
||||||
|
|
||||||
|
## Current State at the End of the Day
|
||||||
|
|
||||||
|
At the end of today’s work, the `OSDev_18` kernel:
|
||||||
|
|
||||||
|
- builds inside the dev container
|
||||||
|
- includes a minimal i386 GDT implementation
|
||||||
|
- loads the GDT during kernel startup
|
||||||
|
- reloads the code and data segment registers correctly
|
||||||
|
- still initializes the VGA terminal
|
||||||
|
- still prints `Hello, World!` after boot
|
||||||
|
|
||||||
|
Based on the testing that was done today, the GDT implementation appears to be working correctly for this minimal protected-mode kernel setup.
|
||||||
|
|
||||||
|
## Later Integration Note
|
||||||
|
|
||||||
|
Later project stages changed the visible startup order so `TerminalInitialize()` runs before `GdtInitialize()` in the final `kernel.c`. This allows later initialization code to print diagnostics through the terminal. The GDT implementation itself remains part of the early startup path and is still initialized before the interrupt, memory, timing, and application layers are used.
|
||||||
43
AdvOpsys/notes/work-summary-2026-04-11-to-14.md
Normal file
43
AdvOpsys/notes/work-summary-2026-04-11-to-14.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Summary of Work Done on Interrupt System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The last 4 days, work was done on implementing interrupt support for the OSDev_18 project. The goal was to allow the kernel to handle both CPU exceptions and hardware interrupts.
|
||||||
|
|
||||||
|
This included setting up the Interrupt Descriptor Table (IDT), implementing basic Interrupt Service Routines (ISRs), adding support for hardware interrupts (IRQs), and handling keyboard input. After completing these steps, the system is now able to respond to interrupts and display output when they occur.
|
||||||
|
|
||||||
|
## IDT Implementation
|
||||||
|
|
||||||
|
The Interrupt Descriptor Table (IDT) was implemented to define how the CPU should respond to interrupts.
|
||||||
|
|
||||||
|
A structure for IDT entries was created, storing the handler address, segment selector, and flags. An IDT pointer structure was also defined to hold the base and size of the table.
|
||||||
|
|
||||||
|
The IDT is initialized in IdtInitialize() and loaded using the lidt instruction. A helper function is used to set up each descriptor.
|
||||||
|
|
||||||
|
## ISR Implementation
|
||||||
|
|
||||||
|
Interrupt Service Routines (ISRs) were implemented to handle CPU exceptions.
|
||||||
|
|
||||||
|
A Registers structure was created to store the CPU state during an interrupt. The IsrHandler() function uses this information to determine which interrupt occurred and prints a message to the terminal.
|
||||||
|
|
||||||
|
Interrupts can also be triggered manually for testing purposes.
|
||||||
|
|
||||||
|
## IRQ Implementation
|
||||||
|
|
||||||
|
Support for hardware interrupts (IRQs) was added for IRQ0 to IRQ15.
|
||||||
|
|
||||||
|
The Programmable Interrupt Controller (PIC) was remapped so that IRQs do not overlap with CPU exceptions. An IrqHandler() function was implemented to handle incoming hardware interrupts and dispatch them to registered handlers.
|
||||||
|
|
||||||
|
End-of-interrupt signals are sent to the PIC after handling each interrupt.
|
||||||
|
|
||||||
|
## Keyboard Implementation
|
||||||
|
|
||||||
|
Keyboard input was implemented using IRQ1.
|
||||||
|
|
||||||
|
A keyboard handler reads scancodes from port 0x60 and translates key presses into ASCII characters using a lookup table. Raw scancodes are kept in a small buffer, while the latest translated character is stored for higher-level terminal and application code to consume.
|
||||||
|
|
||||||
|
This allows the system to respond to user input from the keyboard.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The system now supports basic interrupt handling, including CPU exceptions and hardware interrupts. This is an important step towards building a more advanced and interactive operating system.
|
||||||
164
AdvOpsys/notes/work-summary-2026-04-14.md
Normal file
164
AdvOpsys/notes/work-summary-2026-04-14.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Assignment 4 – Part 1: Memory Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
In this part of the assignment, a basic memory management system was implemented for a simple OS kernel. The goal was to initialize kernel memory, enable paging, and implement dynamic memory allocation using `malloc()` and `free()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implemented Features
|
||||||
|
|
||||||
|
### Kernel Memory Initialization
|
||||||
|
- Used:
|
||||||
|
```c
|
||||||
|
extern uint32_t end;
|
||||||
|
```
|
||||||
|
to get the end address of the kernel from the linker.
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
InitKernelMemory(&end);
|
||||||
|
```
|
||||||
|
- Set up:
|
||||||
|
- `heap_begin`
|
||||||
|
- `heap_end`
|
||||||
|
- `pheap_begin` (page-aligned heap)
|
||||||
|
- `pheap_end`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paging
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
InitPaging();
|
||||||
|
```
|
||||||
|
- Set up:
|
||||||
|
- Page directory at `0x400000`
|
||||||
|
- Page tables starting at `0x404000`
|
||||||
|
- Identity-mapped:
|
||||||
|
- `0x00000000 → 0x00000000`
|
||||||
|
- `0x400000 → 0x400000`
|
||||||
|
- Enabled paging by modifying `cr0` and `cr3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Dynamic Memory Allocation
|
||||||
|
|
||||||
|
#### malloc()
|
||||||
|
- Implemented a simple heap allocator.
|
||||||
|
- Stores metadata using:
|
||||||
|
```c
|
||||||
|
typedef struct {
|
||||||
|
uint8_t status;
|
||||||
|
uint32_t size;
|
||||||
|
} alloc_t;
|
||||||
|
```
|
||||||
|
- Supports:
|
||||||
|
- Sequential allocation
|
||||||
|
- Reuse of freed blocks
|
||||||
|
- Zeroes allocated memory using `memset`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### free()
|
||||||
|
- Frees memory by marking the block as unused.
|
||||||
|
- Prevents errors by:
|
||||||
|
```c
|
||||||
|
if (!mem) return;
|
||||||
|
```
|
||||||
|
- Correctly updates `memory_used`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Block Reuse
|
||||||
|
- When a block is freed, it can be reused by future `malloc()` calls.
|
||||||
|
- Verified by:
|
||||||
|
- Freeing a block
|
||||||
|
- Allocating a smaller block
|
||||||
|
- Observing same address reused
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Page-Aligned Allocation (pmalloc)
|
||||||
|
- Implemented `pmalloc()` and `pfree()`:
|
||||||
|
- Allocates memory in 4KB pages
|
||||||
|
- Uses a descriptor array (`pheap_desc`)
|
||||||
|
- Located in upper memory region near `0x400000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Memory Utility Functions
|
||||||
|
Implemented:
|
||||||
|
- `memcpy()`
|
||||||
|
- `memset()`
|
||||||
|
- `memset16()`
|
||||||
|
|
||||||
|
These replace standard library functions in kernel space.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Debug Output
|
||||||
|
|
||||||
|
#### Memory Layout
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
PrintMemoryLayout();
|
||||||
|
```
|
||||||
|
- Displays:
|
||||||
|
- Memory used
|
||||||
|
- Memory free
|
||||||
|
- Heap size
|
||||||
|
- Heap start/end
|
||||||
|
- Page heap start/end
|
||||||
|
|
||||||
|
#### Allocation Logs
|
||||||
|
- Outputs allocation details:
|
||||||
|
```
|
||||||
|
Allocated X bytes from 0x... to 0x...
|
||||||
|
Re-allocated X bytes from 0x... to 0x...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Hex Output for Addresses
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
TerminalWriteHex(uint32_t num);
|
||||||
|
```
|
||||||
|
- Ensures correct formatting of memory addresses (base 16)
|
||||||
|
- Avoids confusion from decimal output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
|
||||||
|
### Allocation Test
|
||||||
|
```c
|
||||||
|
void* memory1 = malloc(48261);
|
||||||
|
void* memory2 = malloc(27261);
|
||||||
|
void* memory3 = malloc(12617);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Free and Reuse Test
|
||||||
|
```c
|
||||||
|
free(memory2);
|
||||||
|
void* memory4 = malloc(1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result
|
||||||
|
- `memory4` reused the same address as `memory2`
|
||||||
|
- Confirms:
|
||||||
|
- `free()` works
|
||||||
|
- allocator reuses memory correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
- Successfully implemented a basic kernel memory manager
|
||||||
|
- Paging is enabled and functioning
|
||||||
|
- `malloc()` and `free()` work correctly
|
||||||
|
- Memory reuse is verified
|
||||||
|
- Debug output is clear and correctly formatted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
Proceed to **Part 2: PIT and Sleep Functions**
|
||||||
81
AdvOpsys/notes/work-summary-2026-04-15-v2.md
Normal file
81
AdvOpsys/notes/work-summary-2026-04-15-v2.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Assignment 5
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
This assignment implements a simple music player using the **PC speaker (PCSPK)** and the **Programmable Interval Timer (PIT)**.
|
||||||
|
Songs are played by generating frequencies and controlling timing between notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #HowItWorks
|
||||||
|
|
||||||
|
### #PCSpeaker
|
||||||
|
The PC speaker is controlled through **port `0x61`**:
|
||||||
|
- Bits 0 and 1 enable/disable sound output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #PIT
|
||||||
|
The PIT is used for:
|
||||||
|
|
||||||
|
#### #SoundGeneration
|
||||||
|
- Channel 2 generates square wave audio
|
||||||
|
- Frequency is set using:
|
||||||
|
|
||||||
|
divisor = PIT_BASE_FREQ / frequency
|
||||||
|
|
||||||
|
|
||||||
|
#### #Timing
|
||||||
|
- Channel 0 runs at ~1000 Hz
|
||||||
|
- A tick counter is incremented via interrupts
|
||||||
|
- `SleepInterrupt()` is used for accurate note timing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Implementation
|
||||||
|
|
||||||
|
### #Playback
|
||||||
|
Each note contains:
|
||||||
|
- Frequency (Hz)
|
||||||
|
- Duration (ms)
|
||||||
|
|
||||||
|
Playback works by:
|
||||||
|
1. Setting frequency with `PlaySound()`
|
||||||
|
2. Waiting using `SleepInterrupt()`
|
||||||
|
3. Stopping sound with `StopSound()`
|
||||||
|
|
||||||
|
Rests are handled using `R = 0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Challenges
|
||||||
|
|
||||||
|
### #Timing
|
||||||
|
Busy-wait delays caused incorrect timing.
|
||||||
|
This was solved by using interrupt-based sleeping.
|
||||||
|
|
||||||
|
### #QEMU
|
||||||
|
QEMU has limited PC speaker support:
|
||||||
|
- Fast note changes sound unclear
|
||||||
|
- Songs degrade at high speed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Adjustments
|
||||||
|
|
||||||
|
To improve playback clarity:
|
||||||
|
|
||||||
|
duration = original_duration * 2
|
||||||
|
|
||||||
|
|
||||||
|
This slows down the songs and makes notes more distinguishable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
The system successfully:
|
||||||
|
- Generates sound using the PC speaker
|
||||||
|
- Uses PIT for timing and frequency control
|
||||||
|
- Plays songs with multiple notes
|
||||||
|
|
||||||
|
Limitations in QEMU affect sound quality, but the implementation itself works as intended.
|
||||||
221
AdvOpsys/notes/work-summary-2026-04-15.md
Normal file
221
AdvOpsys/notes/work-summary-2026-04-15.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Assignment 4 – Part 2: Programmable Interval Timer
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
In this part of the assignment, work was done on adding a basic driver for the Programmable Interval Timer (PIT) to the `OSDev_18` kernel. The goal was to initialize the PIT, count timer ticks using IRQ0, and use those ticks to support both busy-wait sleeping and interrupt-based sleeping.
|
||||||
|
|
||||||
|
The PIT was configured to use channel 0 and generate regular timer interrupts at `1000 Hz`. This gives a simple timing model where one timer tick is approximately equal to one millisecond.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implemented Features
|
||||||
|
|
||||||
|
### PIT Header and Constants
|
||||||
|
- Added:
|
||||||
|
```c
|
||||||
|
include/kernel/pit.h
|
||||||
|
```
|
||||||
|
- Defined constants for:
|
||||||
|
- PIT command port
|
||||||
|
- PIT channel 0 port
|
||||||
|
- PIT base frequency
|
||||||
|
- target frequency
|
||||||
|
- calculated divider
|
||||||
|
- ticks-per-millisecond conversion
|
||||||
|
|
||||||
|
This provides a single place for PIT-related configuration and function declarations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PIT Initialization
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
void PitInitialize(void);
|
||||||
|
```
|
||||||
|
- Configures the PIT using:
|
||||||
|
```c
|
||||||
|
OutPortByte(PIT_CMD_PORT, 0x36);
|
||||||
|
```
|
||||||
|
- Loads the PIT divisor into channel 0:
|
||||||
|
- low byte first
|
||||||
|
- high byte second
|
||||||
|
- Registers the PIT interrupt handler on:
|
||||||
|
```c
|
||||||
|
IRQ0
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows the kernel to start receiving regular timer interrupts from the PIT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tick Counter
|
||||||
|
- Added a global tick counter:
|
||||||
|
```c
|
||||||
|
static volatile uint32_t pit_ticks = 0;
|
||||||
|
```
|
||||||
|
- Implemented a helper function:
|
||||||
|
```c
|
||||||
|
uint32_t GetCurrentTick(void);
|
||||||
|
```
|
||||||
|
|
||||||
|
The tick counter is incremented every time IRQ0 is triggered. Since the PIT is configured to `1000 Hz`, this gives a simple way to measure elapsed time in milliseconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PIT IRQ Handler
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
static void PitIrqHandler(struct Registers* regs);
|
||||||
|
```
|
||||||
|
- The handler:
|
||||||
|
- ignores the register argument
|
||||||
|
- increments `pit_ticks`
|
||||||
|
|
||||||
|
This keeps the timer interrupt path minimal and lets the rest of the kernel measure time by reading the current tick count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Busy-Wait Sleep
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
void SleepBusy(uint32_t milliseconds);
|
||||||
|
```
|
||||||
|
- Uses the current tick count to wait until enough time has passed
|
||||||
|
- Continuously checks the tick counter in a loop
|
||||||
|
|
||||||
|
This version works by actively spinning on the CPU, so it is simple but inefficient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Interrupt-Based Sleep
|
||||||
|
- Implemented:
|
||||||
|
```c
|
||||||
|
void SleepInterrupt(uint32_t ticks);
|
||||||
|
```
|
||||||
|
- Uses:
|
||||||
|
```c
|
||||||
|
__asm__ volatile ("sti; hlt");
|
||||||
|
```
|
||||||
|
inside a loop
|
||||||
|
- Re-checks the current tick count after each wake-up
|
||||||
|
|
||||||
|
This version is more efficient than busy waiting because the CPU is halted between interrupts instead of constantly polling in a tight loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Kernel Integration
|
||||||
|
- Added:
|
||||||
|
```c
|
||||||
|
#include <kernel/pit.h>
|
||||||
|
```
|
||||||
|
to `kernel.c`
|
||||||
|
- Added:
|
||||||
|
```c
|
||||||
|
PitInitialize();
|
||||||
|
```
|
||||||
|
during kernel startup
|
||||||
|
- Added:
|
||||||
|
```c
|
||||||
|
SleepTest();
|
||||||
|
```
|
||||||
|
during the PIT verification stage. In the final application-oriented kernel flow, this standalone test loop is no longer called from `main()`.
|
||||||
|
|
||||||
|
This connects the PIT driver to the boot flow so the timer logic is available after interrupt setup is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Build System Update
|
||||||
|
- Added:
|
||||||
|
```c
|
||||||
|
src/pit.c
|
||||||
|
```
|
||||||
|
to `CMakeLists.txt`
|
||||||
|
|
||||||
|
This ensures the PIT implementation is compiled and linked into the kernel binary together with the rest of the source files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Terminal Output Fix
|
||||||
|
- Added the missing declaration for:
|
||||||
|
```c
|
||||||
|
void TerminalWriteHex(uint32_t num);
|
||||||
|
```
|
||||||
|
to `terminal.h`
|
||||||
|
|
||||||
|
This was necessary because `TerminalWriteHex()` was already implemented in `terminal.c` and used in several source files, but it was not exposed through the header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
|
||||||
|
### Build Issues Resolved
|
||||||
|
During integration, several issues were found and corrected:
|
||||||
|
|
||||||
|
- incorrect inline assembly syntax:
|
||||||
|
```c
|
||||||
|
__asm__volatile
|
||||||
|
```
|
||||||
|
was corrected to:
|
||||||
|
```c
|
||||||
|
__asm__ volatile
|
||||||
|
```
|
||||||
|
- `printf()` calls were removed from PIT test code and replaced with terminal output functions
|
||||||
|
- the missing `TerminalWriteHex()` declaration in `terminal.h` was added
|
||||||
|
|
||||||
|
These changes allowed the kernel to build again with the PIT source included.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Boot Image Verification
|
||||||
|
- Verified that rebuilding only:
|
||||||
|
```c
|
||||||
|
uiaos-kernel
|
||||||
|
```
|
||||||
|
updates `kernel.bin`, but does not update the bootable ISO image
|
||||||
|
- Confirmed that:
|
||||||
|
- the new `kernel.bin` contained the new PIT- and memory-related strings
|
||||||
|
- the older `kernel.iso` still contained `Hello, World!`
|
||||||
|
- Rebuilt the image using:
|
||||||
|
```c
|
||||||
|
uiaos-create-image
|
||||||
|
```
|
||||||
|
so QEMU would boot the updated kernel
|
||||||
|
|
||||||
|
This explained why old output was still appearing even after the source code had changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Runtime Result
|
||||||
|
After rebuilding the bootable image, the kernel successfully booted into the updated code and displayed:
|
||||||
|
- paging setup output
|
||||||
|
- heap and allocation debug output
|
||||||
|
- the `memory1`, `memory2`, `memory3`, and `memory4` address prints
|
||||||
|
|
||||||
|
This confirms that the updated kernel binary is now being booted correctly.
|
||||||
|
|
||||||
|
The PIT sleep test loop was also verified at runtime. The kernel repeatedly prints the expected sleep messages for both:
|
||||||
|
- busy-wait sleeping
|
||||||
|
- interrupt-based sleeping
|
||||||
|
|
||||||
|
This shows that the PIT tick counter is advancing and that both sleep functions return as expected during execution.
|
||||||
|
|
||||||
|
During this test, one remaining issue was observed in the terminal output: once the VGA text buffer wraps back to the top of the screen, new text overwrites old text directly. The later menu and game flow reduces this by clearing the screen before redrawing, but the terminal still does not implement proper scrolling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
- Added a PIT driver skeleton to the kernel
|
||||||
|
- Configured PIT channel 0 for periodic timer interrupts
|
||||||
|
- Registered an IRQ0 handler and added a global tick counter
|
||||||
|
- Implemented both busy-wait and interrupt-based sleep functions
|
||||||
|
- Integrated PIT support into `kernel.c`
|
||||||
|
- Added `pit.c` to the build system
|
||||||
|
- Fixed the missing `TerminalWriteHex()` declaration
|
||||||
|
- Verified that the updated kernel is now booting from the rebuilt ISO image
|
||||||
|
- Verified that the PIT sleep test loop runs at runtime
|
||||||
|
|
||||||
|
This means the code structure for Part 2 is present in the kernel and has been tested at runtime. The remaining terminal limitation is that continuous logs still wrap instead of scrolling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Later Integration Note
|
||||||
|
The final application framework clears the screen before menu and game redraws, so the wraparound problem is less visible during normal use. Proper terminal scrolling would still be a useful future improvement for debug output.
|
||||||
7
AdvOpsys/notes/work-summary-2026-04-16-to-18.md
Normal file
7
AdvOpsys/notes/work-summary-2026-04-16-to-18.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
I developed a simple application framework inside my OS by creating a menu system that lets the user switch between applications, currently music and Snake. I implemented a basic terminal input method using keyboard interrupts so the user can select an application from the menu and return back to it after use. This demonstrates integration between screen output, keyboard handling, and overall OS control flow.
|
||||||
|
|
||||||
|
For the Snake application, I designed a full game loop with its own game state, including the snake, food, score, and board representation. I used dynamic memory allocation to create and destroy the game state, which shows practical use of memory management in my OS. The game uses PIT/timing to control movement speed and game pacing, while keyboard input is used in real time to change direction, restart, or quit.
|
||||||
|
|
||||||
|
I also integrated sound through the PC speaker to give feedback for events such as eating food, dying, and winning. Overall, this task shows integration of multiple OS components, real-time system programming, practical application of operating system concepts, and creative problem-solving through building an interactive menu-driven game environment.
|
||||||
|
|
||||||
|
Finally everything is printed to the screen using existing TerminalWrite...(); infrastructure.
|
||||||
210
AdvOpsys/notes/work-summary-2026-04-27.md
Normal file
210
AdvOpsys/notes/work-summary-2026-04-27.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Summary of Work Done on April 27, 2026
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Today, work was done on the `OSDev_18` project to add a new piano application to the kernel. The main change was the introduction of a small PC-speaker piano mode that lets the user play notes from the keyboard, record note sequences, and replay recorded songs.
|
||||||
|
|
||||||
|
This work also required changes to the application menu in `kernel.c`, the build system, terminal input handling, and a few small adjustments to existing sound and game code so the new feature could fit into the current menu-driven OS flow.
|
||||||
|
|
||||||
|
## Git Evidence
|
||||||
|
|
||||||
|
The changes described in this note come from the newest local commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7686d3e added piano feature
|
||||||
|
```
|
||||||
|
|
||||||
|
This commit is local work that has not been pushed yet.
|
||||||
|
|
||||||
|
## Piano Application
|
||||||
|
|
||||||
|
The largest part of the work was the addition of a new piano application:
|
||||||
|
|
||||||
|
- Added:
|
||||||
|
```c
|
||||||
|
include/pianoApp/piano.h
|
||||||
|
include/pianoApp/frequencies.h
|
||||||
|
src/piano.c
|
||||||
|
```
|
||||||
|
|
||||||
|
The new piano code introduces:
|
||||||
|
|
||||||
|
- note frequency constants from `C4` up to `C5`
|
||||||
|
- a `Note` structure storing frequency and duration
|
||||||
|
- a `Song` structure storing a recorded note sequence
|
||||||
|
- a `SongLibrary` structure for keeping multiple saved songs
|
||||||
|
- a `PianoAppState` structure for tracking recording state, active note, and timing between notes
|
||||||
|
|
||||||
|
This gives the kernel a dedicated application module instead of placing all piano logic directly inside `kernel.c`.
|
||||||
|
|
||||||
|
## How the Piano Works
|
||||||
|
|
||||||
|
The piano uses the existing PIT and PC-speaker infrastructure already present in the kernel:
|
||||||
|
|
||||||
|
- `PianoPlaySound()` configures PIT channel 2 to generate the selected note frequency
|
||||||
|
- `PianoEnableSpeaker()` and `PianoDisableSpeaker()` control whether the sound is actually sent to the PC speaker
|
||||||
|
- `SleepInterrupt()` is used to control note timing
|
||||||
|
|
||||||
|
The key layout maps keyboard keys such as `z`, `x`, `c`, `v`, `b`, `n`, `m`, and `,` to white keys, while keys such as `s`, `d`, `g`, `h`, and `j` act as black keys.
|
||||||
|
|
||||||
|
The application also supports:
|
||||||
|
|
||||||
|
- `r` to start and stop recording
|
||||||
|
- `p` to play back a stored song
|
||||||
|
- `q` to quit the piano application
|
||||||
|
|
||||||
|
While recording, the code stores both notes and rests. Rests are detected by comparing the current PIT tick count with the time when the previous note ended. This means the playback system can preserve simple pauses between notes instead of only storing the frequencies themselves.
|
||||||
|
|
||||||
|
## Kernel and Menu Integration
|
||||||
|
|
||||||
|
The kernel menu in `src/kernel.c` was expanded so the user can launch the new application.
|
||||||
|
|
||||||
|
The menu now shows:
|
||||||
|
|
||||||
|
- `0. Play Music`
|
||||||
|
- `1. Play Snake`
|
||||||
|
- `2. Play Piano`
|
||||||
|
|
||||||
|
The kernel now includes `piano.h` and calls `PlayPiano()` when the user selects option `2`.
|
||||||
|
|
||||||
|
The menu flow was also cleaned up slightly:
|
||||||
|
|
||||||
|
- the terminal is cleared before entering each application
|
||||||
|
- the terminal is cleared again after returning from an application
|
||||||
|
- invalid input now prints an error, waits briefly using `SleepInterrupt(1000)`, and then clears the screen
|
||||||
|
|
||||||
|
This makes the menu loop more suitable for switching between multiple interactive kernel applications.
|
||||||
|
|
||||||
|
## Terminal and Input Support
|
||||||
|
|
||||||
|
To support the piano playback menu, terminal input handling was extended:
|
||||||
|
|
||||||
|
- Added to `terminal.h`:
|
||||||
|
```c
|
||||||
|
int TerminalGetUInt(uint32_t *number);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Implemented in `terminal.c`:
|
||||||
|
```c
|
||||||
|
int TerminalGetUInt(uint32_t *number);
|
||||||
|
```
|
||||||
|
|
||||||
|
This helper reads a numeric value from keyboard input and converts it into an unsigned integer while checking for invalid characters and overflow.
|
||||||
|
|
||||||
|
It is used when the piano application asks which recorded song should be replayed.
|
||||||
|
|
||||||
|
Another visible input-related change was made in `keyboard.c`:
|
||||||
|
|
||||||
|
- `TerminalPutChar(ascii);`
|
||||||
|
|
||||||
|
was added inside the keyboard handler so typed characters are echoed to the terminal when key presses are processed.
|
||||||
|
|
||||||
|
## Related Adjustments to Existing Code
|
||||||
|
|
||||||
|
Some smaller changes were also made outside the new piano module:
|
||||||
|
|
||||||
|
- `src/songPlayer.c` no longer doubles note duration during playback, so it now uses the exact stored duration values
|
||||||
|
- `src/snake.c` now prints `Press q to exit` on the game screen
|
||||||
|
- `include/libc/limits.h` now defines `UINT32_MAX`, which is needed by `TerminalGetUInt()`
|
||||||
|
|
||||||
|
These are small changes, but they support the new interaction model and make the applications more consistent.
|
||||||
|
|
||||||
|
## Build System Update
|
||||||
|
|
||||||
|
The build configuration was updated so the new piano source is compiled into the kernel:
|
||||||
|
|
||||||
|
- Added to `CMakeLists.txt`:
|
||||||
|
```c
|
||||||
|
src/piano.c
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this change, the new application code would exist in the source tree but would not be linked into the final kernel binary.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
At the end of this work, the source tree contains a third kernel application alongside the existing music player and Snake game. The new code introduces:
|
||||||
|
|
||||||
|
- a keyboard-playable piano
|
||||||
|
- support for recording note sequences
|
||||||
|
- support for replaying saved songs
|
||||||
|
- a menu option for launching the piano from the main kernel loop
|
||||||
|
|
||||||
|
This note reflects the code changes present in the newest local commit. It describes the implementation work, but it does not by itself prove runtime verification in QEMU during this session.
|
||||||
|
|
||||||
|
## Later Debug and Audio Setup Work
|
||||||
|
|
||||||
|
After the piano feature was added, more work was done on the development setup so the new PC-speaker functionality could be tested through the existing VS Code and devcontainer workflow.
|
||||||
|
|
||||||
|
The first step was to compare a new QEMU command snippet against the current `AdvOpsys` setup. That check showed that the suggested command did not fit this machine and repository unchanged.
|
||||||
|
|
||||||
|
This was treated as a local environment compatibility issue, not as a problem in the `OSDev_18` source tree itself.
|
||||||
|
|
||||||
|
The main problems were:
|
||||||
|
|
||||||
|
- the suggested PulseAudio path `/mnt/wslg/PulseServer` is for WSLg, while this machine uses a Linux host Pulse socket instead
|
||||||
|
- the suggested `-s` flag exposes GDB on the default port `1234`, but the repository debug setup already uses port `26000`
|
||||||
|
- the repository boot flow expects `kernel.iso` as the CD image and `disk.iso` as a separate drive, so using `-hda` for `kernel.iso` does not match the current image layout
|
||||||
|
|
||||||
|
## VS Code Debug Script Changes
|
||||||
|
|
||||||
|
The existing QEMU launcher in `.vscode/qemu-debug.sh` was updated so it can choose an audio backend more carefully.
|
||||||
|
|
||||||
|
The script now:
|
||||||
|
|
||||||
|
- prefers PulseAudio when a usable Pulse socket or `PULSE_SERVER` value is available
|
||||||
|
- falls back to SDL audio when PulseAudio is not available
|
||||||
|
- keeps the existing `127.0.0.1:26000` GDB server path used by the VS Code debugger
|
||||||
|
|
||||||
|
This preserves the original debug flow while making the QEMU launch logic less tied to one audio backend.
|
||||||
|
|
||||||
|
## Devcontainer Mount Attempt and Revert
|
||||||
|
|
||||||
|
An attempt was also made to forward the Linux PulseAudio socket into the devcontainer through `.devcontainer/devcontainer.json`.
|
||||||
|
|
||||||
|
That change did not work with the current Docker Desktop `desktop-linux` environment. Rebuilding the container failed because Docker could not bind-mount the expected Pulse runtime directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/run/user/1000/pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
As a result, the PulseAudio mount and related container environment variable were removed again so the devcontainer could build and start normally.
|
||||||
|
|
||||||
|
This failure was specific to the local container/runtime setup on this machine. It did not indicate that the kernel project, the piano code, or the normal image build process were broken.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- the devcontainer build and debug workflow works again
|
||||||
|
- audio support cannot be relied on from the container itself in the same way
|
||||||
|
|
||||||
|
## Host-Side QEMU Workaround
|
||||||
|
|
||||||
|
Because the devcontainer could build and debug the kernel but still had no sound, a separate host-side QEMU workflow was added.
|
||||||
|
|
||||||
|
Two files were updated for this:
|
||||||
|
|
||||||
|
- `.vscode/launch.json`
|
||||||
|
- `.vscode/qemu-debug-host.sh`
|
||||||
|
|
||||||
|
The new host-side setup works by splitting the responsibilities:
|
||||||
|
|
||||||
|
- the kernel is still built from the existing VS Code/devcontainer environment
|
||||||
|
- QEMU is launched on the host, where it can access the real PulseAudio socket
|
||||||
|
- the debugger inside VS Code attaches to that host-side QEMU instance on port `26000`
|
||||||
|
|
||||||
|
This gives a more realistic path for testing PC-speaker audio on this machine, because the emulator process that generates sound now runs in the same environment as the actual audio server.
|
||||||
|
|
||||||
|
In other words, the remaining sound problem was due to where QEMU was running relative to the local audio server, not due to a fault in the repository code.
|
||||||
|
|
||||||
|
## Updated Current State
|
||||||
|
|
||||||
|
At the end of the day, the work on April 27 consisted of both feature development and environment debugging:
|
||||||
|
|
||||||
|
- a new piano application was added to the kernel
|
||||||
|
- the application menu and terminal input code were extended to support it
|
||||||
|
- the QEMU debug setup was reviewed against a new sound-enabled launch command
|
||||||
|
- the container-based PulseAudio mount attempt was tested and then rolled back because it broke devcontainer startup
|
||||||
|
- a host-side QEMU launch script and matching VS Code attach configuration were added as the current workaround for testing sound
|
||||||
|
|
||||||
|
So the codebase now contains the piano implementation itself, and the surrounding debug setup has also been adjusted to better support testing that feature on this specific machine.
|
||||||
|
|
||||||
|
The important distinction is that the main issue here was local system integration between Docker Desktop, the devcontainer, QEMU, and the host audio stack. It was not a core project issue in `AdvOpsys`.
|
||||||
38
AppDev/deployment-section-note.md
Normal file
38
AppDev/deployment-section-note.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Deployment Section Note
|
||||||
|
|
||||||
|
The process of uploading the Android app bundle to Google Play Console and setting up an internal test track should generally **not** be described in the `Solution` section.
|
||||||
|
|
||||||
|
## Why it does not fit `Solution`
|
||||||
|
|
||||||
|
The `Solution` section should primarily explain:
|
||||||
|
|
||||||
|
- what the product is,
|
||||||
|
- which main components it consists of,
|
||||||
|
- how those components solve the user problem.
|
||||||
|
|
||||||
|
Google Play Console setup, app-bundle upload, signing, and internal testing are not really part of the product solution itself. They are part of the **delivery and distribution process**.
|
||||||
|
|
||||||
|
## Better placement
|
||||||
|
|
||||||
|
This material fits better in one of these sections:
|
||||||
|
|
||||||
|
- `Implementation`
|
||||||
|
- if the point is to describe the practical technical work required to prepare the app for Android delivery and real-device testing
|
||||||
|
- `Discussion`
|
||||||
|
- if the point is to reflect on deployment challenges, release-readiness, signing issues, package identity, or lessons learned
|
||||||
|
- `Appendix`
|
||||||
|
- if the point is simply to document that internal testing was configured and completed
|
||||||
|
|
||||||
|
## Recommended approach
|
||||||
|
|
||||||
|
If deployment readiness is important for the report, the cleanest solution is:
|
||||||
|
|
||||||
|
- keep `Solution` focused on the app as a product,
|
||||||
|
- mention Android deployment and internal testing in `Implementation`,
|
||||||
|
- mention any notable release problems or limitations in `Discussion` if relevant.
|
||||||
|
|
||||||
|
## Short report-friendly summary
|
||||||
|
|
||||||
|
If needed, a short version could be phrased like this:
|
||||||
|
|
||||||
|
> Preparation for Android deployment, including app-bundle generation and Google Play internal testing, was treated as part of the implementation and delivery process rather than as part of the product solution itself.
|
||||||
BIN
AppDev/example-report-from-previous-year.pdf
Normal file
BIN
AppDev/example-report-from-previous-year.pdf
Normal file
Binary file not shown.
BIN
AppDev/ikt205_2026_18_study_sprint.zip
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint.zip
Normal file
Binary file not shown.
Binary file not shown.
BIN
AppDev/ikt205_2026_18_study_sprint/Appendices/gitLog.pdf
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint/Appendices/gitLog.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,564 @@
|
|||||||
|
# Study Sprint - Architecture Redesign Notes
|
||||||
|
|
||||||
|
## Purpose of this note
|
||||||
|
This note documents the architectural and UI redesign work completed on the app documenting
|
||||||
|
- what changed
|
||||||
|
- why it changed
|
||||||
|
- how the strucutre improved
|
||||||
|
- which design decisions were intentional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initial app state
|
||||||
|
The app originally had a flatter and more fragmented structure than the actual data model supported.
|
||||||
|
|
||||||
|
The conceptual data hierarchy of the app is:
|
||||||
|
|
||||||
|
**Subject -> Assignment -> Task**
|
||||||
|
|
||||||
|
However, the earlier navigation and screen structure did not consistently reflect that hierarchy. In practice, this caused duplicated views, weak context, and screens that felt disconnected from their parent entities.
|
||||||
|
|
||||||
|
Main problems in the earlier version:
|
||||||
|
- top-level tabs included separate screens for **Subjects**, **Assignments**, and **Tasks**
|
||||||
|
- tasks existed as their own top-level area even though they are children of assignments
|
||||||
|
- assignments also felt partially detached from subjects
|
||||||
|
- repeated CRUD patterns created too many screens and too much UI duplication
|
||||||
|
- many screens relied on older `defaultStyles` patterns rather than a clearer component/card-based structure
|
||||||
|
- raw timestamps and brrittle date inputs created poor usability
|
||||||
|
- create/edit flows were separated even when both used the same form structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core redesign goal
|
||||||
|
The redesign aimed to make the app follow its real conceptual model more closely
|
||||||
|
|
||||||
|
**Dashboard -> overview**
|
||||||
|
**Subjects -> actual content hierarchy entry point**
|
||||||
|
**Timer -> separate utility tool**
|
||||||
|
|
||||||
|
And inside the content structure:
|
||||||
|
|
||||||
|
**Subject -> Assignment -> Task**
|
||||||
|
|
||||||
|
The main design philosophy throughout the redesign was:
|
||||||
|
|
||||||
|
- calm
|
||||||
|
- intuitive
|
||||||
|
- minimal
|
||||||
|
- low visual noise
|
||||||
|
- predictable interaction patterns
|
||||||
|
- stronger hierarchy
|
||||||
|
- less redundancy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Navigation architecture redesign
|
||||||
|
|
||||||
|
## Previous navigation problem
|
||||||
|
The earlier tab setup exposed too many top-level destinations:
|
||||||
|
- Dashboard/Home
|
||||||
|
- Subjects
|
||||||
|
- Assignments
|
||||||
|
- Tasks
|
||||||
|
- Timer
|
||||||
|
|
||||||
|
THis was a problem because Tasks and Assignments were not truly top-level concepts in the product model. They belong to parent entities.
|
||||||
|
|
||||||
|
This caused:
|
||||||
|
- duplicated list views
|
||||||
|
- extra screens with weaker contextual meaning
|
||||||
|
- cognitive overload
|
||||||
|
- a flatter app structure than intended
|
||||||
|
|
||||||
|
## Navigation redesign decision
|
||||||
|
The top-level tabs were simplified to:
|
||||||
|
|
||||||
|
- **Dashboard**
|
||||||
|
- **Subjects**
|
||||||
|
- **Timer**
|
||||||
|
|
||||||
|
## Why this is better
|
||||||
|
This better matches the app structure:
|
||||||
|
- **Dashboard** = overview and corss-cutting information
|
||||||
|
- **Subjects** = main entry point into academic content
|
||||||
|
- **Timer** = standalone utility
|
||||||
|
|
||||||
|
Assignments and tasks were removed from the top-level tab bar and are now only accessed through the hierarchy:
|
||||||
|
- subject details contains assignments
|
||||||
|
- assignment details contain tasks
|
||||||
|
|
||||||
|
### Effect of this change
|
||||||
|
This reduced redundancy and made the app feel more coherent. It also aligned the user flow with the actual data relationships rather than exposing every model as its own first-class navigation area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Content hierarchy redesign
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
Subjects, assignments, and tasks were partially treated like parallel entities rather than nested entities.
|
||||||
|
|
||||||
|
This weakened context:
|
||||||
|
- a task could appear without clearly indicating its assignment or subject
|
||||||
|
- an assignment could feel detached from its subject
|
||||||
|
- the hierarchy existed in the database but was not always communicated in the UI
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
The hierarchy was made explicit in the UI
|
||||||
|
|
||||||
|
- subjects page shows only subject cards
|
||||||
|
- subject detail page becomes the main hub fro the selected subject
|
||||||
|
- assignment detail page becomes the main hub for the selected assignment
|
||||||
|
- task detail page becomes the main hub for the selected task
|
||||||
|
|
||||||
|
### Result
|
||||||
|
The app now better communicates:
|
||||||
|
- where the user is
|
||||||
|
- what the current item belongs to
|
||||||
|
- how to move deeper into the structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Subject list redesign
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
The subject list screen contained too much management UI directly in the list:
|
||||||
|
- edit buttons
|
||||||
|
- delete buttons
|
||||||
|
- progress bar
|
||||||
|
- cluttered card actions
|
||||||
|
|
||||||
|
This made the subject list feel like a control panel rather than a clean browsing screen.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
The subject list was simplified into clean, tappable subject cards.
|
||||||
|
|
||||||
|
Each subject card now focuses on:
|
||||||
|
- subject title
|
||||||
|
- optional description
|
||||||
|
- active/inactive pill
|
||||||
|
- subject-specific color identity
|
||||||
|
- full-card tap to open subject details
|
||||||
|
|
||||||
|
### Removed from list cards
|
||||||
|
- inline edit button
|
||||||
|
- inline delete button
|
||||||
|
- progress bar
|
||||||
|
- management-heavy layout
|
||||||
|
|
||||||
|
### Why
|
||||||
|
The list screen should be for browsing and selecting. Management actions belong inside the detail screen for that entitiy
|
||||||
|
|
||||||
|
### Result
|
||||||
|
The subject list become calmer, easier to scan, and more aligned with the principle that cards should act as entry points rather than mini dashboards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Subject detail screen redesign
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
The subject detail screenw as still using older styling patterns and did not fully behave as the subject "hub".
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
The subject detail screen was redesigned as the main management hub for the specific subject.
|
||||||
|
|
||||||
|
It now includes:
|
||||||
|
- a subject summary card
|
||||||
|
- subject status and metadata
|
||||||
|
- subject-specific color styling
|
||||||
|
- subject actions (edit/delete)
|
||||||
|
- create assignment action
|
||||||
|
- assignment sections below the subject summary
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
This screen now clearly answers:
|
||||||
|
- what this subject is
|
||||||
|
- how active/complete it is
|
||||||
|
- what assignments belong to it
|
||||||
|
- what actions can be taken at the subject level
|
||||||
|
|
||||||
|
This better reflects the product structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Assignment detail screen redesign
|
||||||
|
|
||||||
|
### Original issue
|
||||||
|
Assignments were previously styled more generically and did not always preserve clear subject context.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
The assignment detail screen was redesigned to:
|
||||||
|
- function as the hub for one assignment
|
||||||
|
- show clear metadata
|
||||||
|
- show progress through child tasks
|
||||||
|
- expose only assignment-relevant actions
|
||||||
|
- preserve visual inheritance from the parent subject
|
||||||
|
|
||||||
|
### New structure
|
||||||
|
The assignment detail screen now includes:
|
||||||
|
- assignment summary card
|
||||||
|
- subject context pill
|
||||||
|
- deadline metadata
|
||||||
|
- progress section showing task completion
|
||||||
|
- task list organized by completion state
|
||||||
|
- create task action
|
||||||
|
|
||||||
|
### Why
|
||||||
|
Assignments are not independent from subjects. The redesign makes that relationship visible without making the screen visually noisy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Task detail screen redesign
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
Tasks were at risk of losing hierarchical context because they are the deepest level in the model.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
The task detail screen was redesigned to preserve parent context explicitly.
|
||||||
|
|
||||||
|
It now includes:
|
||||||
|
- task summary card
|
||||||
|
- subject context pill
|
||||||
|
- assignment context pill
|
||||||
|
- task state and metadata
|
||||||
|
- parent-aware styling
|
||||||
|
|
||||||
|
### Why
|
||||||
|
Tasks are the easiest place for context loss. By surfacing both subject and assignment, the user always knows where the task belongs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Upsert-based form architecture
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
The app originally used separate create and edit screens for the same entities, even when both screens used almost identical form fields and validation.
|
||||||
|
|
||||||
|
This created:
|
||||||
|
- duplicated UI code
|
||||||
|
- repeated logic
|
||||||
|
- more route clutter
|
||||||
|
- higher maintenance cost
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
Create/edit flows were consolidated into upsert-style screens where appropriate.
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- `upsertSubject`
|
||||||
|
- `upsertAssignment`
|
||||||
|
- `upsertTask`
|
||||||
|
|
||||||
|
### Pattern used
|
||||||
|
The form checks whether an ID exists:
|
||||||
|
- if no ID exists -> create mode
|
||||||
|
- if an ID exists -> edit mode
|
||||||
|
|
||||||
|
The same screen handles:
|
||||||
|
- initial default values
|
||||||
|
- loading existing data when editing
|
||||||
|
- submit behavior for insert vs update
|
||||||
|
|
||||||
|
### Why this is better
|
||||||
|
This reduces duplication while keeping the form styling and validation consistent.
|
||||||
|
|
||||||
|
### Tradeoff
|
||||||
|
This introduces a little more branching inside a single screen, but the tradeoff is worth it because create and edit flows are structurally very similar for these entities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Shared utility extraction
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
Small but important logic was duplicated across screens.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
Shared helpers were moved into reusable modules.
|
||||||
|
|
||||||
|
### Added shared utilities
|
||||||
|
- `lib/date.ts`
|
||||||
|
- `lib/subjectColors.ts`
|
||||||
|
|
||||||
|
## Date formatting utility
|
||||||
|
A shared date formatting module was introduced to standardize:
|
||||||
|
- date-only display
|
||||||
|
- date-time display
|
||||||
|
|
||||||
|
This replaced raw timestamp rendering such as ISO strings.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
Raw database timestamps were ugly and difficult to read. Formatting them centrally improves both UI quality and consistency.
|
||||||
|
|
||||||
|
## Subject color utility
|
||||||
|
A shared subject color configuration was introduced to centralize:
|
||||||
|
- available subject colors
|
||||||
|
- subject color type
|
||||||
|
- mapping from logical color key to visual values
|
||||||
|
- helper function for retrieving the correct color set
|
||||||
|
|
||||||
|
### Why
|
||||||
|
This prevents duplicated color logic and ensures consistent us of subject-specific accent colors across screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Subject color system and inheritance
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
The app initially relied mainly on global app accent colors, which made it harder to preserve identity across nested subject content.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
Subjects were given their own user-selectable accent color.
|
||||||
|
|
||||||
|
### Color choice
|
||||||
|
The user chooses from a controlled palette instead of arbitrary colors.
|
||||||
|
|
||||||
|
### Why a controlled palette
|
||||||
|
This preserves:
|
||||||
|
- aesthetic consistency
|
||||||
|
- readability
|
||||||
|
- predictable contrast
|
||||||
|
- low visual noise
|
||||||
|
|
||||||
|
## How color is used
|
||||||
|
The subject color is used as a contextual accent, not a replacement for the whole theme.
|
||||||
|
|
||||||
|
Used on:
|
||||||
|
- subject card border
|
||||||
|
- subject preview card
|
||||||
|
- subject pills
|
||||||
|
- inherited borders and indicators in assignment/task screens
|
||||||
|
- progress bars and completion indicators where appropriate
|
||||||
|
|
||||||
|
## Important design rule
|
||||||
|
The subject color was not used for everything.
|
||||||
|
|
||||||
|
Primary action buttons such as:
|
||||||
|
- create
|
||||||
|
- save
|
||||||
|
- login
|
||||||
|
remain on the **global app accent**
|
||||||
|
|
||||||
|
### Why
|
||||||
|
This preserves a consistent interaction language:
|
||||||
|
- app accent = primary action
|
||||||
|
- subject color = content identity / context
|
||||||
|
|
||||||
|
This separation was an intentional design decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Card-based UI redesign
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
Several screens still relied on older layout/styling conventions that felt less coherent and more cluttered.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
The redesign shifted toward a more consistent card-based interface using:
|
||||||
|
- bordered surface cards
|
||||||
|
- semantic Tailwind theme classes
|
||||||
|
- more restrained spacing
|
||||||
|
- contextual pills
|
||||||
|
- reduced action clutter
|
||||||
|
|
||||||
|
### Card design goals
|
||||||
|
- easier scanning
|
||||||
|
- stronger visual hierarchy
|
||||||
|
- fewer floating controls
|
||||||
|
- more predictable composition
|
||||||
|
|
||||||
|
### Result
|
||||||
|
The app feels more structured and less noisy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Metadata and pill system
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
Status and metadata were previously shown in less consistent ways, including redundant indicators.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
Metadata display was standardized using pill elements for small contextual information.
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
- subject name
|
||||||
|
- assignment parent
|
||||||
|
- deadline
|
||||||
|
- active/inactive state
|
||||||
|
|
||||||
|
## Important cleanup decision
|
||||||
|
Some pills were removed when they became redundant.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- completed/in-progress pill was removed in places where the same information was already communicated by a checkbox or progress structure
|
||||||
|
|
||||||
|
### Why
|
||||||
|
This reduced duplication and visual clutter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Progress display redesign
|
||||||
|
|
||||||
|
## Original issue
|
||||||
|
Progress indicators were previously placed too aggressively in list views where they created clutter.
|
||||||
|
|
||||||
|
## Redesign decision
|
||||||
|
Progress bars were kept only where they make structural sense:
|
||||||
|
- subject detail
|
||||||
|
- assignment detail
|
||||||
|
|
||||||
|
### Why
|
||||||
|
These are hub screens where progress is meaningful.
|
||||||
|
|
||||||
|
Progress bars were intentionally removed from places like subject list cards where they overloaded a browsing view.
|
||||||
|
|
||||||
|
### Additional improvement
|
||||||
|
Progress is now shown with both:
|
||||||
|
- a percentage bar
|
||||||
|
- an `x / y` completed count
|
||||||
|
- remaining item count text
|
||||||
|
|
||||||
|
This makes progress more understandable than a bar alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Authentication-related debugging insight
|
||||||
|
During development, a major debugging issue turned out not to be screen architecture at all, but session/auth failure.
|
||||||
|
|
||||||
|
This surfaced as:
|
||||||
|
- fetch failures
|
||||||
|
- apparent data loading errors
|
||||||
|
- misleading “network request failed” behavior
|
||||||
|
|
||||||
|
### Takeaway
|
||||||
|
Auth state and session expiry can easily masquerade as architecture or fetch bugs.
|
||||||
|
|
||||||
|
This reinforced the importance of:
|
||||||
|
- clearer auth handling
|
||||||
|
- not assuming every fetch failure is a UI/data issue
|
||||||
|
- checking session state early when debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Time handling and dual-boot issue insight
|
||||||
|
A separate development issue was discovered related to system time mismatch in a Windows/Linux dual-boot environment.
|
||||||
|
|
||||||
|
Although not an app architecture feature directly, it affected development by causing:
|
||||||
|
- failed requests
|
||||||
|
- misleading network/auth behavior
|
||||||
|
|
||||||
|
### Development takeaway
|
||||||
|
System time correctness matters for:
|
||||||
|
- authentication
|
||||||
|
- HTTPS
|
||||||
|
- tokens
|
||||||
|
- scheduled features
|
||||||
|
|
||||||
|
This was important context during debugging and implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Notifications and reminders
|
||||||
|
Assignment creation/updating included work around deadline reminders.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
When an assignment has a valid future deadline:
|
||||||
|
- a reminder can be scheduled
|
||||||
|
- previous reminders are updated/cancelled when necessary
|
||||||
|
- notification IDs are stored through async storage helpers
|
||||||
|
|
||||||
|
### Why this matters architecturally
|
||||||
|
This means assignment upsert behavior is not only CRUD. It also coordinates:
|
||||||
|
- persistence
|
||||||
|
- reminder scheduling
|
||||||
|
- reminder cleanup
|
||||||
|
|
||||||
|
This is relevant for the final report because it shows that form flows have side effects beyond database writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. General design principles used across the redesign
|
||||||
|
|
||||||
|
## Principle 1: reflect the true data hierarchy
|
||||||
|
The UI should match the conceptual model:
|
||||||
|
- subjects contain assignments
|
||||||
|
- assignments contain tasks
|
||||||
|
|
||||||
|
## Principle 2: remove redundant top-level structure
|
||||||
|
Not every data model deserves a top-level tab.
|
||||||
|
|
||||||
|
## Principle 3: keep list screens for browsing
|
||||||
|
Heavy management actions should live in detail screens.
|
||||||
|
|
||||||
|
## Principle 4: preserve context
|
||||||
|
The deeper the user goes, the more important parent context becomes.
|
||||||
|
|
||||||
|
## Principle 5: use color as identity, not decoration
|
||||||
|
Subject colors provide contextual identity without overwhelming the UI.
|
||||||
|
|
||||||
|
## Principle 6: keep primary actions globally consistent
|
||||||
|
App accent remains the primary action language.
|
||||||
|
|
||||||
|
## Principle 7: reduce duplication
|
||||||
|
Reusable upsert screens and shared utilities reduce maintenance cost.
|
||||||
|
|
||||||
|
## Principle 8: avoid visual noise
|
||||||
|
Redundant pills, repeated indicators, and crowded cards were intentionally reduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Current architecture summary
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
- Dashboard
|
||||||
|
- Subjects
|
||||||
|
- Timer
|
||||||
|
|
||||||
|
## Hierarchy
|
||||||
|
- Subject
|
||||||
|
- Assignment
|
||||||
|
- Task
|
||||||
|
|
||||||
|
## Reusable utilities
|
||||||
|
- date formatting
|
||||||
|
- subject color mapping
|
||||||
|
- progress checks
|
||||||
|
- async storage notification helpers
|
||||||
|
|
||||||
|
## Form strategy
|
||||||
|
- upsert-style forms for core entities
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
- NativeWind
|
||||||
|
- semantic Tailwind tokens
|
||||||
|
- shared card/pill patterns
|
||||||
|
- subject color inheritance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Final outcome
|
||||||
|
The redesign changed the app from a flatter CRUD-style structure into a more coherent hierarchical study workflow.
|
||||||
|
|
||||||
|
The main improvements were:
|
||||||
|
- better navigation structure
|
||||||
|
- reduced redundancy
|
||||||
|
- clearer subject/assignment/task relationships
|
||||||
|
- stronger contextual design
|
||||||
|
- less cluttered list screens
|
||||||
|
- reusable form patterns
|
||||||
|
- centralized shared helpers
|
||||||
|
- more polished and consistent UI
|
||||||
|
|
||||||
|
Overall, the app now better reflects its intended purpose as a study productivity tool organized around the academic structure of:
|
||||||
|
**Subject → Assignment → Task**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. Possible items to mention later in the final report
|
||||||
|
These are likely worth discussing explicitly:
|
||||||
|
|
||||||
|
- why Tasks and Assignments were removed as top-level tabs
|
||||||
|
- why subject detail and assignment detail were turned into hubs
|
||||||
|
- why create/edit were merged into upsert patterns
|
||||||
|
- why a controlled color palette was used instead of arbitrary colors
|
||||||
|
- why subject color was used for context, not for primary actions
|
||||||
|
- why duplicated metadata indicators were removed
|
||||||
|
- why shared date formatting and subject color utilities were extracted
|
||||||
|
- how preserving hierarchy improved usability
|
||||||
|
- how debugging/auth issues affected development decisions
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Timer Element Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
This note documents the timer work completed by **Chris Sanden** in the Study-Sprint project.
|
||||||
|
|
||||||
|
The git history shows a dedicated timer commit:
|
||||||
|
- Commit: `d50301cb04837b196110cea43ff15c0493c5fac2`
|
||||||
|
- Short hash: `d50301c`
|
||||||
|
- Author: `Chris Sanden <c.sanden@outlook.com>`
|
||||||
|
- Date: `2026-04-21`
|
||||||
|
- Message: `First draft of timer element`
|
||||||
|
- File added: `app/(tabs)/timer.tsx`
|
||||||
|
- Branch references at inspection time: `timer`, `origin/timer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #TimerTab
|
||||||
|
Created the first draft of a standalone timer screen:
|
||||||
|
- Added `app/(tabs)/timer.tsx`
|
||||||
|
- Implemented the timer as its own tab while the final task-start flow is still planned
|
||||||
|
- Used React Native and Expo tab routing conventions already present in the project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DurationSelector
|
||||||
|
Implemented a horizontal animated selector for timer durations:
|
||||||
|
- Uses `Animated.FlatList`
|
||||||
|
- Supports snap scrolling with `snapToInterval`
|
||||||
|
- Shows selectable durations from `1` to `60`
|
||||||
|
- Uses scaled and faded text animation so the centered duration is emphasized
|
||||||
|
- Updates the selected duration when scrolling ends
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerAnimation
|
||||||
|
Implemented the first timer start animation:
|
||||||
|
- Added a circular start button
|
||||||
|
- Button fades and moves down after the timer starts
|
||||||
|
- Timer overlay animates into view
|
||||||
|
- Timer overlay then animates out based on the selected duration
|
||||||
|
- Uses `Animated.sequence` and `useNativeDriver`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #UserInterface
|
||||||
|
|
||||||
|
The timer screen includes:
|
||||||
|
- Full-screen dark background
|
||||||
|
- Red timer overlay
|
||||||
|
- Large centered duration numbers
|
||||||
|
- Circular red start button near the bottom of the screen
|
||||||
|
- Hidden status bar for a focused timer view
|
||||||
|
|
||||||
|
The visual direction is a simple first draft intended to make the timer interaction testable before deeper integration with tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #PlannedIntegration
|
||||||
|
|
||||||
|
The in-code note describes the intended next step:
|
||||||
|
- Keep the timer as a separate tab initially
|
||||||
|
- Later open the timer when a user starts a task
|
||||||
|
- Replace the current duration-number area with task information such as:
|
||||||
|
- Task name
|
||||||
|
- Task description
|
||||||
|
- Potentially add an animated character or visual element if time allows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #GitEvidence
|
||||||
|
|
||||||
|
The work attributed to Chris is supported by this git log entry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d50301c Chris Sanden 2026-04-21 First draft of timer element
|
||||||
|
```
|
||||||
|
|
||||||
|
The commit added one new file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A app/(tabs)/timer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The file was later also touched in commit `cb6368a` by `Teodor` on `2026-04-22` as part of broader UI and routing fixes. The original timer implementation documented here is the `d50301c` commit authored by Chris.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
Chris implemented the first functional timer draft for the application. The work established a standalone timer tab, duration selection, animated start behavior, and a clear path for later connecting the timer to task-start workflows.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Timer UI and Countdown Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the standalone timer screen was developed further before wiring it into the task system.
|
||||||
|
|
||||||
|
The main focus was improving the timer interaction and learning how the React Native animation flow works. The timer is still being treated as its own tab for now, with placeholder task data used in place of real task integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #TaskInformationPlaceholder
|
||||||
|
Added placeholder task information to the timer screen:
|
||||||
|
- Placeholder task name
|
||||||
|
- Placeholder task description
|
||||||
|
- Fade-in animation when the timer starts
|
||||||
|
- Fade-out animation when the timer finishes
|
||||||
|
|
||||||
|
This prepares the timer UI for the later task integration, where the placeholder values can be replaced by real task data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #AdjacentTimerFade
|
||||||
|
Updated the timer duration selector so adjacent numbers fade away when the timer starts:
|
||||||
|
- The centered selected value remains visible
|
||||||
|
- Neighboring values fade out during the active timer state
|
||||||
|
- Neighboring values are intended to fade back in after the timer finishes
|
||||||
|
|
||||||
|
This was implemented by separating the normal picker opacity from the active timer opacity and combining them with `Animated.add` and `Animated.multiply`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #MeasuredTimerHeight
|
||||||
|
Started adjusting the timer overlay to use the measured screen/container height:
|
||||||
|
- Added `containerHeight`
|
||||||
|
- Added `onLayout` to measure the actual timer screen area
|
||||||
|
- Updated timer overlay movement to use the measured container height
|
||||||
|
|
||||||
|
This was done because the full window height does not always match the visible tab screen area when headers, tab bars, or safe areas are involved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CountdownDisplay
|
||||||
|
Added countdown display logic:
|
||||||
|
- Added `timeRemaining`
|
||||||
|
- Added `selectedIndex`
|
||||||
|
- Added `formatTime(totalSeconds)`
|
||||||
|
- Converted the selected timer value into a `MM:SS` display while running
|
||||||
|
- Added `TIMER_UNIT_IN_SECONDS` so timer values can behave as seconds during development and minutes later
|
||||||
|
|
||||||
|
Current development behavior:
|
||||||
|
- `TIMER_UNIT_IN_SECONDS = 1`
|
||||||
|
- Selecting `5` means a 5-second timer
|
||||||
|
|
||||||
|
Planned production behavior:
|
||||||
|
- `TIMER_UNIT_IN_SECONDS = 60`
|
||||||
|
- Selecting `5` means a 5-minute timer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CountdownFadeControl
|
||||||
|
Started separating countdown visibility from the rest of the timer UI:
|
||||||
|
- Added `countdownAnimation`
|
||||||
|
- Added `showCountdownText`
|
||||||
|
- Began separating the `MM:SS` countdown fade from the button and picker fade
|
||||||
|
- Fixed the nested animation callback syntax after adding the countdown fade-out flow
|
||||||
|
|
||||||
|
The goal is for the countdown text to fade out first, then for the button and adjacent timer values to fade back in after the countdown is gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #LearningNotes
|
||||||
|
|
||||||
|
### #ReactState
|
||||||
|
Worked with several pieces of state:
|
||||||
|
- `duration` stores the selected timer value
|
||||||
|
- `isRunning` tracks whether the timer is active
|
||||||
|
- `timeRemaining` stores the countdown value
|
||||||
|
- `selectedIndex` identifies which duration is selected
|
||||||
|
- `showCountdownText` controls whether the selected item renders as `MM:SS`
|
||||||
|
- `containerHeight` stores the measured height of the timer screen
|
||||||
|
|
||||||
|
Important distinction:
|
||||||
|
- State values trigger re-renders when changed
|
||||||
|
- Animated values drive smooth visual changes without normal React state updates on every animation frame
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #Hooks
|
||||||
|
Clarified where hooks are allowed:
|
||||||
|
- `useState`, `useRef`, `useEffect`, and `useCallback` must be called inside the component
|
||||||
|
- Hooks must not be placed inside callbacks, conditionals, loops, or event handlers
|
||||||
|
- `useEffect` dependency arrays must be inside the `useEffect(...)` call
|
||||||
|
|
||||||
|
One key bug came from an effect without a proper dependency array. Because the countdown updates state every second, the effect ran every second and reset the red overlay position.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #AnimationFlow
|
||||||
|
The timer now uses multiple animated values:
|
||||||
|
- `timerAnimation` controls the red overlay movement
|
||||||
|
- `buttonAnimation` controls the start button and inactive timer value visibility
|
||||||
|
- `taskDetailsAnimation` controls the placeholder task information
|
||||||
|
- `countdownAnimation` controls the `MM:SS` countdown visibility
|
||||||
|
|
||||||
|
The main lesson was that one animation value should not control too many unrelated visual states. Separate animation values make it easier to control the order of fade-out and fade-in transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
The timer file syntax issue around the end of the `animation` callback was fixed.
|
||||||
|
|
||||||
|
Current lint result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
The previous parse error was caused by mismatched closing braces/parentheses near the nested `.start(...)` callbacks at the end of the animation sequence.
|
||||||
|
|
||||||
|
The remaining behavior to confirm is the final transition order:
|
||||||
|
- `MM:SS` countdown should fade out
|
||||||
|
- selected text should switch back to the normal timer value while hidden
|
||||||
|
- adjacent timer values should fade back in
|
||||||
|
- start button should fade back in
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main file worked on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/(tabs)/timer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-04-22.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
The timer UI moved from a basic animated duration selector toward a more complete timer experience. It now has placeholder task information, a `MM:SS` countdown concept, measured layout support, and separate animation values for different UI elements.
|
||||||
|
|
||||||
|
The syntax error at the end of the animation callback has been fixed and lint now passes. The remaining immediate work is to finish confirming the final fade-out/fade-in ordering so the countdown disappears cleanly before the picker and start button return.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Timer Interaction and Cancel Flow Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the standalone timer screen was developed further with a focus on the cancel interaction, countdown reset order, and a progress cue inside the cancel button.
|
||||||
|
|
||||||
|
The main work was not just adding UI pieces, but understanding how the existing React Native `Animated` flow behaves when a timer is started, cancelled, or allowed to finish naturally. The timer is still being treated as its own tab with placeholder task information, but the interaction model is now closer to the intended study-session behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #CancelButton
|
||||||
|
Added a dedicated cancel control for the active timer state:
|
||||||
|
- Added a separate cancel button animation value
|
||||||
|
- Added a bottom-positioned cancel button that appears only during the running state
|
||||||
|
- Added reverse handling so the button can be dismissed again when cancelling manually or when the timer finishes
|
||||||
|
|
||||||
|
The main goal was to keep the original large start control as the primary entry point, while giving the active timer state its own secondary exit action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CancelProgressCue
|
||||||
|
Started adding a progress cue directly inside the cancel button:
|
||||||
|
- Added a separate `cancelProgressAnimation`
|
||||||
|
- Added an inner animated fill layer inside the cancel button
|
||||||
|
- Changed the progress direction to move left-to-right inside the button instead of using a full-button opacity fade
|
||||||
|
|
||||||
|
This was done to match the visual language of the main red timer overlay while keeping the progress indicator smaller and more local to the cancel action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DurationLocking
|
||||||
|
Updated the duration selector to stay fixed while the timer is running:
|
||||||
|
- Added `scrollEnabled={!timerIsRunning}` to the horizontal timer picker
|
||||||
|
- Added an early return inside `onMomentumScrollEnd`
|
||||||
|
- Prevented the selected timer duration from changing once a session has started
|
||||||
|
|
||||||
|
This keeps the timer state consistent after the session begins and avoids the picker drifting into a visually different value while the countdown is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CountdownOwnership
|
||||||
|
Clarified how the countdown interval should be owned and reset:
|
||||||
|
- Added `countdownRef`
|
||||||
|
- Added interval clearing before starting a new countdown
|
||||||
|
- Used the ref-based interval handle so cancel and finish logic can target the active countdown
|
||||||
|
|
||||||
|
This work was needed because countdown behavior becomes unreliable if the code starts new intervals without keeping a consistent reference to the currently running one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CancelFlowSequencing
|
||||||
|
Worked on the ordering of reverse animations during manual cancel:
|
||||||
|
- Tested separating countdown fade-out from the picker/start-button return
|
||||||
|
- Investigated why adjacent numbers were reappearing before the countdown text had fully finished reversing
|
||||||
|
- Traced the problem to both animation timing and the `showCountdownText` render condition
|
||||||
|
|
||||||
|
The important lesson here was that hiding the countdown visually and switching the rendered text back to the normal timer value are related, but not identical, events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #LearningNotes
|
||||||
|
|
||||||
|
### #AnimatedValueResponsibilities
|
||||||
|
Today reinforced that each `Animated.Value` should have one clear responsibility:
|
||||||
|
- `timerAnimation` controls the red overlay position
|
||||||
|
- `buttonAnimation` controls start-button disappearance and inactive picker return
|
||||||
|
- `countdownAnimation` controls countdown visibility
|
||||||
|
- `cancelButtonAnimation` controls the cancel button itself
|
||||||
|
- `cancelProgressAnimation` controls the left-to-right fill inside the cancel button
|
||||||
|
|
||||||
|
Several visual bugs came from trying to make one animated value carry two different meanings at the same time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #RenderStateVsAnimationState
|
||||||
|
A key distinction became clearer during the cancel-flow debugging:
|
||||||
|
- Animated values control motion and opacity
|
||||||
|
- Regular React state controls what text/content is actually rendered
|
||||||
|
|
||||||
|
One important example is `showCountdownText`:
|
||||||
|
- Even if the countdown has visually faded out, the selected timer item still renders `MM:SS` while `showCountdownText` remains `true`
|
||||||
|
- This means the UI can still appear to be in “countdown mode” even after part of the reverse animation has already completed
|
||||||
|
|
||||||
|
This is why some cancel-order issues were not purely animation problems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #SequenceVsParallel
|
||||||
|
The timer work also clarified when `Animated.sequence([...])` and `Animated.parallel([...])` should be used:
|
||||||
|
- `sequence` is for strict order
|
||||||
|
- `parallel` is for animations that should run at the same time
|
||||||
|
|
||||||
|
One mistake that surfaced during the progress-button work was placing the long progress-fill animation in a sequence after the main timer animation, which caused the fill to begin only after the timer had already ended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentIssue
|
||||||
|
|
||||||
|
The current timer screen still has remaining cancel-flow polish issues around visual timing and overlay cleanup.
|
||||||
|
|
||||||
|
The main issue currently under investigation is the reset order during manual cancel:
|
||||||
|
- the red timer overlay can still produce a visible flash/jump when the running animation is interrupted
|
||||||
|
- the adjacent picker numbers and selected countdown text are sensitive to both animation order and `showCountdownText`
|
||||||
|
- the current implementation needs further refinement so cancel feels deliberate instead of visually noisy
|
||||||
|
|
||||||
|
Current lint result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint
|
||||||
|
completed with 1 warning
|
||||||
|
```
|
||||||
|
|
||||||
|
Current warning:
|
||||||
|
- unnecessary `showCountdownText` dependency in one `useCallback`
|
||||||
|
|
||||||
|
There are no current lint errors, but the cancel interaction is not yet considered visually finished.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main file worked on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/(tabs)/timer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-04-23.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
The timer screen moved further toward a complete active-session interaction today. It now has a dedicated cancel control, a left-to-right progress cue inside that control, a locked duration picker while running, and a clearer separation between countdown ownership and animation ownership.
|
||||||
|
|
||||||
|
The main remaining work is not basic feature addition, but interaction polish. In particular, the cancel sequence still needs refinement so the red overlay, countdown text, and adjacent timer values return in a clean and intentional order.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Timer Focus Mode and Hold-Cancel Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the standalone timer screen was reworked further with a focus on the active sprint layout, countdown ownership, and the hold-to-cancel interaction.
|
||||||
|
|
||||||
|
The main direction was to make the running timer feel more like a focused study state instead of a duration picker that happens to count down. The countdown was moved toward a separate overlay, the task details were given more visual emphasis, and the cancel interaction was changed from a simple button press into a deliberate hold action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #CountdownOverlay
|
||||||
|
Moved the active countdown away from the duration picker:
|
||||||
|
- Removed the old selected-picker countdown state
|
||||||
|
- Added a separate countdown overlay using `countdownAnimation`
|
||||||
|
- Added `focusModeAnimation` so the countdown can move from the central timer area toward the upper-left area
|
||||||
|
- Kept the picker responsible for duration values only
|
||||||
|
|
||||||
|
This separates two responsibilities that had previously been mixed together: the picker selects a duration, while the overlay shows active countdown time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #FocusModeLayout
|
||||||
|
Adjusted the active timer layout to put more attention on the task:
|
||||||
|
- Moved task details higher and closer to the center of the running screen
|
||||||
|
- Increased the task title and description size
|
||||||
|
- Kept task details animated through `taskDetailsAnimation`
|
||||||
|
- Continued using the red screen overlay as the main visual timer-progress element
|
||||||
|
|
||||||
|
The intent is for the active state to feel more like a study-session spotlight, where the selected task becomes the main focus and the countdown becomes supporting information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #HoldToCancel
|
||||||
|
Changed the cancel action into a hold interaction:
|
||||||
|
- Added `HOLD_TO_CANCEL_MS`
|
||||||
|
- Added `cancelHoldTimeoutRef`
|
||||||
|
- Added a hold-completion haptic warning
|
||||||
|
- Kept the cancel button scale feedback during press
|
||||||
|
- Changed the label to `Hold to end sprint`
|
||||||
|
|
||||||
|
This makes cancellation more deliberate and reduces the chance of accidentally ending a sprint with a single tap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CancelAccelerationExperiment
|
||||||
|
Implemented the red timer overlay as cancel feedback:
|
||||||
|
- Added delayed cancel acceleration through `CANCEL_ANIMATION_DELAY_MS`
|
||||||
|
- Added `cancelHoldAnimationDelayRef`
|
||||||
|
- Added `cancelAccelStartedRef` to distinguish quick taps from actual hold acceleration
|
||||||
|
- Split normal timer progress into `progressAnimationRef`
|
||||||
|
- Added `startProgressAnimation(fromY)` so progress can start or resume from a specific overlay position
|
||||||
|
- Added `cancelOverlayAnimation` as a temporary visual offset on top of the real timer progress
|
||||||
|
- Added `getCancelOverlayTarget(...)` to calculate how far the cancel preview should move
|
||||||
|
- Added a release handoff animation so the cancel offset eases back into the real timer position
|
||||||
|
- Added clamping so the visual overlay does not move past the finished timer position
|
||||||
|
- Added easing constants for the cancel delay, release handoff, and timer reset timings
|
||||||
|
|
||||||
|
The goal was for the red overlay to speed toward the finished position during a hold, then return smoothly to the real timer progress if the user releases before the cancel completes. The important change is that cancel preview motion is now layered on top of the real progress instead of directly taking over the main timer animation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DurationPickerCleanup
|
||||||
|
Cleaned up the duration picker after moving countdown ownership out of it:
|
||||||
|
- Removed selected countdown rendering from the picker item
|
||||||
|
- Kept picker items rendering plain timer values
|
||||||
|
- Kept picker values fading out during active timer mode
|
||||||
|
- Added index clamping when reading the selected duration from `onMomentumScrollEnd`
|
||||||
|
- Restored `duration` as a dependency of the start callback so the selected picker value is used correctly
|
||||||
|
|
||||||
|
This fixed the earlier issue where the timer could behave as if the selected duration was still the initial value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerCodeCleanup
|
||||||
|
Cleaned up the timer screen structure after the interaction behavior was stabilized:
|
||||||
|
- Renamed the old `animation` callback to `startTimer`
|
||||||
|
- Renamed unclear animated values like `opacity` and `translateY` to `startButtonOpacity` and `startButtonTranslateY`
|
||||||
|
- Grouped refs by purpose: animated values, timer/session refs, and cancel-hold refs
|
||||||
|
- Extracted `clearCountdown`, `clearCancelHoldTimers`, and `stopTimerAnimations`
|
||||||
|
- Extracted the cancel overlay target calculation into `getCancelOverlayTarget(...)`
|
||||||
|
- Split the render section into local render helpers for the overlay, start button, cancel button, countdown, duration picker, and task details
|
||||||
|
- Moved the timer item layout into `styles.timerItem`
|
||||||
|
|
||||||
|
This did not change the screen into a separate hook or split the timer into multiple files. The cleanup stayed local to `timer.tsx` so the current animation work remains easy to inspect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #LearningNotes
|
||||||
|
|
||||||
|
### #AnimationOwnership
|
||||||
|
The main lesson today was that an `Animated.Value` should have one clear owner at a time.
|
||||||
|
|
||||||
|
The red overlay now combines two animated values:
|
||||||
|
- normal timer progress
|
||||||
|
- hold-to-cancel visual offset
|
||||||
|
|
||||||
|
The normal timer progress is controlled by `timerAnimation`, while cancel preview motion is controlled by `cancelOverlayAnimation`. This avoids stopping the real timer progress just to show the cancel speed-up effect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #RefsAsMutableState
|
||||||
|
Several refs were added to track animation and timer ownership:
|
||||||
|
- `progressAnimationRef` tracks the long-running red overlay progress animation
|
||||||
|
- `sessionStartedAtRef` tracks the progress timeline used for recovery calculations
|
||||||
|
- `sessionDurationMsRef` stores the current timer duration in milliseconds
|
||||||
|
- `cancelHoldTimeoutRef` tracks when hold cancellation should complete
|
||||||
|
- `cancelHoldAnimationDelayRef` tracks when cancel acceleration should begin
|
||||||
|
- `cancelAccelStartedRef` tracks whether the red overlay acceleration actually started
|
||||||
|
- `cancelHoldActiveRef` and `cancelHoldIdRef` prevent stale delayed hold callbacks from taking over after release
|
||||||
|
|
||||||
|
The important distinction is that assigning to `.current` is allowed even when the ref variable itself is declared with `const`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CancelOffsetHandoff
|
||||||
|
The release recovery logic was changed to avoid rewriting the real timer progress:
|
||||||
|
- keep `timerAnimation` running as the source of real timer progress
|
||||||
|
- add `cancelOverlayAnimation` on top of it while the cancel button is held
|
||||||
|
- animate only the cancel offset back to `0` when the hold is released
|
||||||
|
- keep the visible overlay clamped to the screen height
|
||||||
|
- tune the release handoff timing with `CANCEL_RELEASE_MS`
|
||||||
|
|
||||||
|
This makes the visual red overlay return to the countdown's real timer position without forcing the main timer animation to stop and restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
The hold-cancel red overlay interaction has been reworked so the cancel preview no longer directly mutates the real timer progress.
|
||||||
|
|
||||||
|
The current implementation:
|
||||||
|
- keeps the countdown and real timer progress owned by `timerAnimation`
|
||||||
|
- uses `cancelOverlayAnimation` as a temporary visual offset during hold-to-cancel
|
||||||
|
- invalidates stale hold callbacks with `cancelHoldIdRef`
|
||||||
|
- eases the cancel offset back to `0` on release
|
||||||
|
- keeps the cancel-completion path separate from normal timer completion
|
||||||
|
|
||||||
|
This should make the red overlay speed-up feel connected to the cancel hold while still keeping the timer progress visually aligned with the countdown after release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
Current static checks pass:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
The hold-cancel handoff was also adjusted based on runtime feedback so the cancel offset eases back more smoothly into the real timer progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main file worked on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/(tabs)/timer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-04-24.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
The timer screen moved further toward a focused active-sprint experience. The countdown is now separated from the duration picker, task details have more visual weight, and cancel is treated as a deliberate hold action rather than a normal tap.
|
||||||
|
|
||||||
|
The main animation change is that hold-to-cancel now keeps the real timer progress separate from the temporary cancel speed-up effect. The code was also cleaned up so the timer flow is easier to read and continue working on.
|
||||||
|
|
||||||
|
## Problems occuring after writing conclusion
|
||||||
|
Tried to implement sound by installing expo-audio. This caused the dependency list to update. The diff was massive, and something in the diff caused the entire timer page to break. Logic, animations - the lot. Have reverted back to last known working dependency list, as well as un-refactored a lot of code in an attempt to revert to a functioning state before figuring out that the culprit was dependencies. Need to figure our what is causing the critical failure in the new list.
|
||||||
|
|
||||||
|
## Todo
|
||||||
|
- Re-refactor to make code cleaner, more readable and easier to maintain.
|
||||||
|
- Figure out the dependency issues of later dependency lists
|
||||||
|
|
||||||
|
## Conclusion of dependecy saga
|
||||||
|
There was a mismatch in the nativewind dependency, with my one being ^4.2.3 and the other list being ^4.1.23. This cause my entire timer screen to fail. Animations got borked, buttons not working properly, duration picker only showing 2 indexes... the works. Solution - keepp nativewind dependency to ^4.2.3
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Timer Refactor and Verification Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the timer screen was worked on with a narrower goal than yesterday: not new interaction features, but cleanup, readability, and making the existing timer flow easier to understand and maintain.
|
||||||
|
|
||||||
|
This follows directly from yesterday's state. The April 24 note ended with two follow-up items:
|
||||||
|
- re-refactor the timer code so it becomes easier to read and work on
|
||||||
|
- keep the dependency situation stable after the NativeWind version mismatch had broken the screen
|
||||||
|
|
||||||
|
Today's work focused on the first of those. The interaction model was kept the same, but the internal structure of `timer.tsx` was cleaned up so the current hold-to-cancel and focus-mode behavior is easier to inspect without splitting the code into hooks or separate files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #TimerCodeRefactor
|
||||||
|
Refactored the timer screen structure inside `app/(tabs)/timer.tsx`:
|
||||||
|
- renamed the component from `App` to `TimerScreen`
|
||||||
|
- renamed unclear callbacks such as the old generic start-animation function into `startTimerSession`
|
||||||
|
- grouped the file more clearly into constants, animated values, refs, derived values, actions, render helpers, and JSX
|
||||||
|
- renamed vague animated/interpolated values to clearer names such as `startButtonOpacity`, `startButtonTranslateY`, and `pickerOpacity`
|
||||||
|
|
||||||
|
This did not change the screen architecture into multiple files. The cleanup stayed local to the timer file so the animation flow is still easy to inspect in one place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CleanupHelpers
|
||||||
|
Extracted repeated timer cleanup work into small local helpers:
|
||||||
|
- added `clearCountdownInterval()`
|
||||||
|
- added `clearCancelHoldTimeouts()`
|
||||||
|
- added `stopRunningAnimations()`
|
||||||
|
- added `resetSessionValues()`
|
||||||
|
|
||||||
|
Before this, the same interval, timeout, and animation-reset work was spread across multiple callbacks. Pulling it into helpers makes it easier to follow what happens when a session starts, finishes, or is cancelled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #RenderStructureCleanup
|
||||||
|
Cleaned up the render section so it is easier to read:
|
||||||
|
- moved repeated inline layout styles into named `StyleSheet` entries
|
||||||
|
- extracted the timer picker item rendering into a local `renderTimerItem(...)` helper
|
||||||
|
- kept the JSX order aligned with the visible screen layers: overlay, start button, cancel button, countdown, duration picker, and task details
|
||||||
|
|
||||||
|
This mainly improves scanning. The old file worked, but the render section made you jump between inline style objects and animation expressions to understand each layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CommentAndNamingPass
|
||||||
|
Added a small number of comments only where the code was genuinely hard to follow:
|
||||||
|
- clarified that `timerAnimation` owns real timer progress
|
||||||
|
- clarified that `cancelOverlayAnimation` is only a temporary visual offset during hold-to-cancel
|
||||||
|
- clarified that `startProgressAnimation(fromY)` resumes overlay progress from the current Y position
|
||||||
|
- clarified why cancel acceleration starts after a short delay
|
||||||
|
|
||||||
|
The aim was not to comment every line, but to explain the parts that are hard to infer just by reading the code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #StateResetTightening
|
||||||
|
Made the session cleanup more explicit:
|
||||||
|
- reset `sessionStartedAtRef` and `sessionDurationMsRef` when a session ends
|
||||||
|
- reset cancel-hold flags during session cleanup
|
||||||
|
- made `finishTimer()` explicitly clear the countdown interval before running exit animations
|
||||||
|
- kept the existing unmount cleanup so intervals, timeouts, and running animations are not left behind if the screen disappears mid-session
|
||||||
|
|
||||||
|
These are small changes, but they make the timer lifecycle more predictable and reduce the amount of stale mutable state left around after finish or cancel paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #LearningNotes
|
||||||
|
|
||||||
|
### #ReadableCodeVsNewFeatures
|
||||||
|
Today's timer work was a good reminder that "more maintainable" does not always mean "more abstract."
|
||||||
|
|
||||||
|
For this screen, the right cleanup level was:
|
||||||
|
- better names
|
||||||
|
- smaller local helpers
|
||||||
|
- clearer grouping
|
||||||
|
- a few targeted comments
|
||||||
|
|
||||||
|
The wrong cleanup level for the current stage would have been moving the logic into extra hooks or files too early, because that would make it harder to inspect the animation flow while the interaction is still being tuned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #MutableRefOwnership
|
||||||
|
The timer file still relies heavily on refs because several parts of the interaction are long-lived and imperative:
|
||||||
|
- active countdown interval
|
||||||
|
- running start animation
|
||||||
|
- running progress animation
|
||||||
|
- delayed cancel-preview start
|
||||||
|
- hold-to-cancel completion timeout
|
||||||
|
|
||||||
|
The cleanup made this easier to see by separating refs that hold animated values from refs that track mutable timer/session ownership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
Compared with yesterday, the timer interaction model is mostly the same, but the code behind it is more structured.
|
||||||
|
|
||||||
|
The current implementation:
|
||||||
|
- keeps the red overlay model used yesterday
|
||||||
|
- keeps `timerAnimation` as the real progress owner
|
||||||
|
- keeps `cancelOverlayAnimation` as the temporary hold-preview layer
|
||||||
|
- keeps the delayed hold acceleration and release recovery flow
|
||||||
|
- keeps all timer logic local to `timer.tsx`
|
||||||
|
- is now easier to read because repeated cleanup and render logic have been extracted into named local pieces
|
||||||
|
|
||||||
|
This means today's work was mainly a recovery and consolidation pass after yesterday's interaction-heavy changes and the earlier dependency-related breakage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
Today's static checks passed after the refactor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
git diff --check -- 'app/(tabs)/timer.tsx'
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
There was no new timer commit for today at the time of writing this note. The summary above is based on:
|
||||||
|
- the current working-tree diff for `app/(tabs)/timer.tsx`
|
||||||
|
- the verification commands run after the refactor
|
||||||
|
- yesterday's note and timer history for context
|
||||||
|
|
||||||
|
I did not do a live Expo interaction test inside this note workflow, so runtime behavior is verified statically plus by code review rather than by manually pressing through the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main file worked on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/(tabs)/timer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-04-25.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
The main timer work today was not adding new features, but making yesterday's feature-rich timer implementation is easier to continue working on.
|
||||||
|
|
||||||
|
The result is a timer file that keeps the same focus-mode and hold-to-cancel behavior, while being more readable, more structured, and easier to maintain. The biggest improvement is that the important ideas in the file now have clearer names, clearer ownership, and clearer cleanup paths.
|
||||||
|
|
||||||
|
The timer is now considered finished and ready to implement into the rest of the project.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Task Timer Integration and App Polish Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the timer work moved from being a standalone tab experiment into the actual task workflow.
|
||||||
|
|
||||||
|
The main commit used for this summary is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
c74062c Implemented timer into task details, uploaded example images for app and centred headers on all screens
|
||||||
|
```
|
||||||
|
|
||||||
|
The work focused on connecting the sprint timer to individual tasks, preserving an active sprint locally, cleaning up routing, and polishing the surrounding app presentation. The timer is no longer exposed as a top-level tab. It now belongs to the task details flow where a sprint naturally starts from a selected task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #TimerRouteIntegration
|
||||||
|
Moved the timer route from the tab navigator into the task stack:
|
||||||
|
- removed the old timer tab from `app/(tabs)/_layout.tsx`
|
||||||
|
- added `timer` as a screen in `app/task/_layout.tsx`
|
||||||
|
- moved the timer implementation from `app/(tabs)/timer.tsx` to `app/task/timer.tsx`
|
||||||
|
- added a `Start Sprint` action from the task details screen
|
||||||
|
- passed the selected task id into the timer route using `tId`
|
||||||
|
|
||||||
|
This makes the timer part of the task workflow instead of a separate global screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TaskAwareTimer
|
||||||
|
Updated the timer screen so it can load and display the selected task:
|
||||||
|
- reads `tId` from route params
|
||||||
|
- fetches the matching task from Supabase
|
||||||
|
- shows the task title and description during the sprint
|
||||||
|
- falls back to generic sprint text if task data is missing
|
||||||
|
|
||||||
|
This replaces the earlier placeholder-task model and makes the sprint screen reflect the actual task being worked on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ActiveSprintPersistence
|
||||||
|
Added local persistence for the current sprint in `lib/asyncStorage.ts`:
|
||||||
|
- added an `ActiveSprint` type
|
||||||
|
- added `SaveActiveSprint(...)`
|
||||||
|
- added `GetActiveSprint()`
|
||||||
|
- added `RemoveActiveSprint()`
|
||||||
|
- stores the active task id, sprint duration, and calculated end time
|
||||||
|
|
||||||
|
The timer now saves the sprint end time when a session starts. When the timer screen is reopened for the same task, it can restore the remaining sprint time instead of treating the session as gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimeBasedCountdown
|
||||||
|
Changed the countdown ownership toward wall-clock time:
|
||||||
|
- calculates `endTime` when the sprint starts
|
||||||
|
- updates remaining time from `Date.now()`
|
||||||
|
- restores progress from elapsed time when an active sprint is found
|
||||||
|
- removes the active sprint when it expires or is cancelled
|
||||||
|
|
||||||
|
This is a step toward making the timer more robust when the app is backgrounded or the timer screen is reopened.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #HoldCancelOverlayWork
|
||||||
|
Continued work on the red hold-to-cancel timer overlay:
|
||||||
|
- kept `timerAnimation` as the main timer-progress value
|
||||||
|
- kept `cancelOverlayAnimation` as the temporary hold-preview offset
|
||||||
|
- added measured overlay height handling through `containerHeight`
|
||||||
|
- added an offscreen reset position for the red overlay
|
||||||
|
- added `timerOverlayVisible` so the red overlay can be hidden immediately after manual cancel fires
|
||||||
|
|
||||||
|
The final direction was to stop relying only on moving the red overlay offscreen. The cancel path now also hides the overlay by opacity before the rest of the return animations run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #HeaderAlignmentPolish
|
||||||
|
Centered navigation titles across the main app screens:
|
||||||
|
- dashboard
|
||||||
|
- subjects
|
||||||
|
- subject create/edit
|
||||||
|
- subject details
|
||||||
|
- assignment create/edit
|
||||||
|
- assignment details
|
||||||
|
- task create/edit
|
||||||
|
- task details
|
||||||
|
- sprint timer
|
||||||
|
|
||||||
|
This was a small visual consistency pass, but it makes the app feel less uneven between screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ImageAssetUpdate
|
||||||
|
Updated the app image assets:
|
||||||
|
- replaced the main icon and splash image files under `assets/images/`
|
||||||
|
- moved `master.png` into `assets/images/`
|
||||||
|
- removed the older `assets/study-sprint-image-pack/` copies
|
||||||
|
|
||||||
|
This keeps the active image assets in the folder Expo expects instead of keeping a separate image-pack folder around.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ProblemsAndSetbacks
|
||||||
|
|
||||||
|
### #CancelOverlayBug
|
||||||
|
The main setback today was the red hold-to-cancel overlay still being visible after manual cancel.
|
||||||
|
|
||||||
|
Several possible causes were investigated:
|
||||||
|
- the overlay using `Dimensions.get('window')` instead of the measured container height
|
||||||
|
- the overlay being clamped to the screen height
|
||||||
|
- the overlay view being stretched by `StyleSheet.absoluteFillObject`
|
||||||
|
- the overlay not being moved far enough below the screen
|
||||||
|
|
||||||
|
The first fixes improved the logic but did not remove the visible red bar in runtime feedback. The latest approach adds explicit overlay visibility state so the red timer layer can be hidden directly when cancel completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ManualCancelVsNaturalFinish
|
||||||
|
Another important clarification was that the problematic path was manual cancel, not the natural "timer reached zero" flow.
|
||||||
|
|
||||||
|
The cancel path has extra moving parts:
|
||||||
|
- hold delay
|
||||||
|
- hold completion timeout
|
||||||
|
- `cancelOverlayAnimation`
|
||||||
|
- haptic warning
|
||||||
|
- return animations
|
||||||
|
|
||||||
|
That made the bug harder to reason about than the normal finish path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #RuntimeConfidence
|
||||||
|
Static checks passed, but the final visual fix still needs manual runtime confirmation on the device/emulator.
|
||||||
|
|
||||||
|
The timer animation issue is visual and interaction-dependent, so TypeScript and lint can confirm that the code is valid, but they cannot prove that the red overlay is gone in the actual UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
The current unpushed commit has the timer integrated into the task flow.
|
||||||
|
|
||||||
|
The app now supports:
|
||||||
|
- starting a sprint from task details
|
||||||
|
- opening the timer as `/task/timer`
|
||||||
|
- loading the selected task into the sprint screen
|
||||||
|
- saving active sprint state locally
|
||||||
|
- restoring active sprint progress from stored end time
|
||||||
|
- cancelling and clearing active sprint state
|
||||||
|
- centered headers across the main screens
|
||||||
|
- updated Expo image assets
|
||||||
|
|
||||||
|
The remaining runtime risk is the red hold-to-cancel overlay. The newest implementation hides the overlay with explicit `timerOverlayVisible` state after manual cancel, but this still needs to be verified by pressing through the cancel flow in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
Static checks were run after the timer changes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint
|
||||||
|
exited successfully with one existing warning
|
||||||
|
```
|
||||||
|
|
||||||
|
The lint warning is unrelated to today's timer work:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/(tabs)/subjects.tsx
|
||||||
|
React Hook useCallback has a missing dependency: 'GetSubjects'
|
||||||
|
```
|
||||||
|
|
||||||
|
The summary above is based on the unpushed commit diff from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
origin/timerTask..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main timer and task-flow files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/task/timer.tsx
|
||||||
|
app/task/_layout.tsx
|
||||||
|
app/task/viewDetailsTask.tsx
|
||||||
|
lib/asyncStorage.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigation polish files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/(tabs)/_layout.tsx
|
||||||
|
app/(tabs)/index.tsx
|
||||||
|
app/(tabs)/subjects.tsx
|
||||||
|
app/assignment/upsertAssignment.tsx
|
||||||
|
app/assignment/viewDetailsAssignment.tsx
|
||||||
|
app/subject/upsertSubject.tsx
|
||||||
|
app/subject/viewDetailsSubject.tsx
|
||||||
|
app/task/upsertTask.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Image asset files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/images/
|
||||||
|
assets/study-sprint-image-pack/
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-05-01.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
Today's work moved the timer from an isolated feature into the real task workflow.
|
||||||
|
|
||||||
|
The biggest progress was routing and ownership: a sprint now starts from a task, carries that task id into the timer, displays task details during the sprint, and stores active sprint state locally. The surrounding app also received a small consistency pass through centered headers and updated image assets.
|
||||||
|
|
||||||
|
The main setback was the manual hold-to-cancel red overlay bug. The implementation has gone through several attempts, and the current version now hides the overlay directly after cancel instead of relying only on moving it out of view. The next step is to verify that final visual behavior live in the app.
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Timer Session Tracking and Dashboard Integration Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the timer work moved beyond local in-memory behavior and into a more durable sprint-session model.
|
||||||
|
|
||||||
|
The main direction was to make sprint time count toward task progress in a safer way, while also surfacing that progress in the app UI. This meant extending the timer flow with database-backed sprint sessions, making task time visible on the task details screen, and continuing the dashboard integration so active or upcoming work is easier to reach.
|
||||||
|
|
||||||
|
The work stayed focused on the timer/task/dashboard path rather than broad app refactoring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #SprintSessionPersistence
|
||||||
|
Moved the timer session model toward a more robust database-backed structure:
|
||||||
|
- created a `sprint_sessions` table in Supabase
|
||||||
|
- added a `sessionId` field to the local `ActiveSprint` type in `lib/asyncStorage.ts`
|
||||||
|
- updated the timer start flow to create a sprint session in the database before entering the running timer state
|
||||||
|
- kept local `active_sprint` storage as the resume handle, but now tied it to a real database session instead of only a task id and end time
|
||||||
|
|
||||||
|
This changes the active sprint from being only a local timer state into a recordable session that can later be finalized safely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TaskTimeTracking
|
||||||
|
Added task-level study time tracking:
|
||||||
|
- added `totalTimeInSeconds` to the task model in `lib/types.ts`
|
||||||
|
- verified that cancelling a sprint updates both `sprint_sessions` and the task total in the database
|
||||||
|
- verified that expired sessions also finalize correctly and contribute time as expected
|
||||||
|
|
||||||
|
This gives each task a running total of time spent, rather than leaving the timer as a standalone UI action with no durable result on the task itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #FinalizeFlowRepair
|
||||||
|
Adjusted the timer finalize flow so session teardown and restore logic stop fighting each other:
|
||||||
|
- added a `finalizeSprintSession(...)` path in `app/task/timer.tsx`
|
||||||
|
- updated natural finish, cancel, and expired restore paths to use the database finalize flow
|
||||||
|
- removed the local active sprint before the finalize RPC completes so the restore effect does not immediately re-open a just-cancelled timer
|
||||||
|
- added alerts for sprint-session creation/finalization failures instead of silently leaving the screen in a half-running state
|
||||||
|
|
||||||
|
This fixed the case where cancelling the timer appeared to work visually, but then the sprint popped back open because restore logic still saw a locally active session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerStartGuarding
|
||||||
|
Tightened the sprint-start path in the timer screen:
|
||||||
|
- delayed `setIsRunning(true)` until after the `start_sprint_session` RPC succeeds
|
||||||
|
- added handling for the returned session id before local sprint state is saved
|
||||||
|
- added fallback handling for session id shape differences in the RPC response
|
||||||
|
|
||||||
|
Before this, the timer UI could enter a partial running state if the database session failed to start, which made the header change without actually starting the timer animation flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TaskDetailsTimeDisplay
|
||||||
|
Made the recorded task time visible in the task details screen:
|
||||||
|
- added a local formatter for tracked time in `app/task/viewDetailsTask.tsx`
|
||||||
|
- displayed `Time spent: ...` under the existing metadata block on the task details screen
|
||||||
|
|
||||||
|
This is the first direct UI confirmation that the timer is affecting persistent task data rather than only changing temporary timer state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardSprintVisibility
|
||||||
|
Extended the dashboard so it reflects timer/task state more clearly:
|
||||||
|
- added dashboard support for reading and displaying the current active sprint from local storage
|
||||||
|
- showed the active sprint task title, description, and remaining time
|
||||||
|
- added an `Open Sprint` action that links directly back into the running timer
|
||||||
|
|
||||||
|
This gives the user a global way to get back to an already running sprint after navigating away from the timer screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #UpcomingDeadlineCards
|
||||||
|
Added a deadline-based task section to the dashboard:
|
||||||
|
- added a `Tasks with upcoming deadlines` section to the dashboard state
|
||||||
|
- fetched active tasks together with their assignment and subject context
|
||||||
|
- sorted the tasks by assignment deadline in ascending order
|
||||||
|
- rendered clickable cards that open the relevant task details screen
|
||||||
|
- updated the metadata line at the bottom of each card to show subject, assignment, and deadline
|
||||||
|
|
||||||
|
This makes the dashboard more useful as a next-action screen instead of only a placeholder when no sprint is running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardTaskCompletion
|
||||||
|
Extended the dashboard task cards so they can directly affect task progress:
|
||||||
|
- added a `Mark as completed` action to each upcoming task card
|
||||||
|
- updated the action to write `isCompleted = true` back to the matching task row
|
||||||
|
- reused `CheckAssignmentCompletion(...)` so assignment completion status stays in sync with task completion
|
||||||
|
- removed the completed task from the dashboard list immediately after a successful update
|
||||||
|
- added a confirmation alert before the completion update runs
|
||||||
|
|
||||||
|
This gives the dashboard a lightweight task-management action instead of making it read-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardAndSubjectsHelpFlow
|
||||||
|
Added a small help/info entry point to explain the app structure more clearly:
|
||||||
|
- added a help button in the dashboard header
|
||||||
|
- added the same help/info pattern to the subjects screen
|
||||||
|
- opened a compact modal that explains the app flow as `Subject -> Assignment -> Task -> Sprint`
|
||||||
|
- added a clear summary block and a closing action inside the modal
|
||||||
|
|
||||||
|
This gives new users a direct explanation of how the app model fits together without leaving the current screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #HeaderAndStylingPolish
|
||||||
|
Continued local UI cleanup around the dashboard and subjects screens:
|
||||||
|
- aligned the header actions so help and logout use the same general layout pattern on both screens
|
||||||
|
- converted the dashboard screen from local `StyleSheet` usage to NativeWind/Tailwind class-based styling
|
||||||
|
- converted the subjects help/modal block away from `styles.*` references and into NativeWind classes
|
||||||
|
- kept the visual structure local to the affected screens rather than introducing shared abstractions
|
||||||
|
|
||||||
|
This keeps the dashboard and subjects screens stylistically closer to each other while staying within the current app structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ActiveSprintDashboardFix
|
||||||
|
Fixed a rendering bug in the dashboard state logic:
|
||||||
|
- the `Tasks with upcoming deadlines` section had been placed inside the `no active sprint` branch
|
||||||
|
- when a sprint was active, the upcoming task section disappeared entirely
|
||||||
|
- moved the upcoming task section out of that conditional so both the active sprint card and upcoming tasks render together
|
||||||
|
|
||||||
|
This keeps the dashboard useful while a sprint is already running instead of hiding the rest of the user's near-term work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ProblemsAndSetbacks
|
||||||
|
|
||||||
|
### #QuotedColumnNames
|
||||||
|
The first major issue today came from the new sprint-session SQL functions using unquoted camelCase column names.
|
||||||
|
|
||||||
|
The database columns used names such as:
|
||||||
|
- `sessionId`
|
||||||
|
- `taskId`
|
||||||
|
- `startedAt`
|
||||||
|
- `elapsedSeconds`
|
||||||
|
|
||||||
|
Without quotes, Postgres treated these as lowercase names like `sessionid` and `taskid`, which caused RPC failures when starting or finalizing sprint sessions.
|
||||||
|
|
||||||
|
This had to be corrected in the SQL functions before the app-side timer integration could work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #RowLevelSecurity
|
||||||
|
The next blocker was row-level security on `sprint_sessions`.
|
||||||
|
|
||||||
|
Even after the SQL functions matched the correct columns, session creation still failed until the insert/select/update permissions allowed authenticated users to work with their own sprint-session rows.
|
||||||
|
|
||||||
|
This was a necessary database-layer fix before the new robust timer flow could be tested end to end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CancelRestoreRace
|
||||||
|
Another significant bug showed up after the new finalize flow was wired in:
|
||||||
|
- the cancel animation ran
|
||||||
|
- the timer visually closed
|
||||||
|
- then the sprint reopened immediately
|
||||||
|
|
||||||
|
The cause was that the restore effect still found `active_sprint` in local storage while the cancel/finalize path was still finishing. Removing the local active sprint earlier in the finalize path fixed that race.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardListInterpretation
|
||||||
|
There was also a dashboard-listing issue where the upcoming-deadlines section could appear to only show tasks from one subject.
|
||||||
|
|
||||||
|
The actual cause was not the subject join itself, but the fact that the list had been truncated after sorting. That made the section biased toward whichever subject owned the earliest deadlines in the current data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #UpcomingTasksVisibilityBug
|
||||||
|
Another dashboard bug appeared after the active sprint card had been added.
|
||||||
|
|
||||||
|
The issue was not in the Supabase query itself. The problem was that the upcoming-deadlines section was rendered only in the `no active sprint` branch of the dashboard conditional.
|
||||||
|
|
||||||
|
This meant:
|
||||||
|
- the active sprint card appeared correctly
|
||||||
|
- the upcoming task data was still loaded
|
||||||
|
- but the list was hidden whenever a sprint existed
|
||||||
|
|
||||||
|
The fix was to move the upcoming task section outside that conditional so the dashboard can show both at once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
The timer/task flow now goes further than yesterday's integration work.
|
||||||
|
|
||||||
|
The app now supports:
|
||||||
|
- creating a real sprint session in the database when a timer starts
|
||||||
|
- finalizing sprint sessions on cancel and expiry
|
||||||
|
- adding tracked session time into `tasks.totalTimeInSeconds`
|
||||||
|
- showing tracked task time on the task details screen
|
||||||
|
- reopening the active sprint from the dashboard
|
||||||
|
- showing upcoming deadline task cards even while a sprint is active
|
||||||
|
- marking upcoming dashboard tasks as completed with a confirmation step
|
||||||
|
- opening a help/info modal from both the dashboard and subjects headers
|
||||||
|
- using NativeWind/Tailwind styling for the dashboard screen and the subjects help modal block
|
||||||
|
|
||||||
|
At this point, the timer is no longer only integrated into the task route. It is now also contributing durable progress data back into the task model and exposing more of that state in surrounding screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
During today's work, the following behaviors were verified manually through the app plus database inspection:
|
||||||
|
- sprint creation now succeeds after fixing quoted column names and RLS
|
||||||
|
- cancelling a sprint updates both `sprint_sessions` and `tasks.totalTimeInSeconds`
|
||||||
|
- expired sprint finalization also updates the database as expected
|
||||||
|
- the cancel flow no longer reopens the timer immediately after the close animation
|
||||||
|
|
||||||
|
Static checks were also run during the implementation work:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint -- app/task/timer.tsx
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint -- app/task/viewDetailsTask.tsx
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run lint -- app/(tabs)/index.tsx
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx eslint app/(tabs)/index.tsx
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx eslint app/(tabs)/subjects.tsx
|
||||||
|
completed with one existing warning
|
||||||
|
```
|
||||||
|
|
||||||
|
The later dashboard/subjects polish work was verified with local static checks and code inspection. The summary above is based on today's working-tree changes plus the live runtime/database checks done while fixing the timer session flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main timer/session files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/task/timer.tsx
|
||||||
|
lib/asyncStorage.ts
|
||||||
|
lib/types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Task details and dashboard files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/task/viewDetailsTask.tsx
|
||||||
|
app/(tabs)/index.tsx
|
||||||
|
app/(tabs)/subjects.tsx
|
||||||
|
lib/progress.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-05-02.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
Today's work turned the timer into something closer to a real task-tracking feature instead of only a screen-local countdown.
|
||||||
|
|
||||||
|
The biggest progress was introducing sprint sessions as database-backed records, finalizing them into tracked task time, and then surfacing that state back into the app through task details and the dashboard. The later dashboard follow-up made that surrounding UI more useful by keeping upcoming tasks visible during active sprints, allowing quick task completion from the dashboard itself, and adding a lightweight in-app explanation of the subject/assignment/task/sprint model.
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
# Focus, Dashboard, And Progress Model Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today the timer work moved from a sprint-only model toward a more general session flow that can support both focused work and breaks.
|
||||||
|
|
||||||
|
The main goal was to start closing the vision gap around `focus -> break -> continue`, while keeping the implementation local to the existing timer route instead of introducing a larger navigation or state-management rewrite.
|
||||||
|
|
||||||
|
The work therefore covered both app-side session-model changes and the Supabase function updates needed to make the new flow actually start and finalize sessions correctly.
|
||||||
|
|
||||||
|
Later in the same work session, the scope also expanded into the dashboard and the progress presentation on the detail screens so the app better matches the remaining vision-gap plan.
|
||||||
|
|
||||||
|
The scope then expanded one step further into first-time-user friction, so the work also covered a guided onboarding path and clearer empty states for new accounts.
|
||||||
|
|
||||||
|
Later still, the work expanded beyond the app itself into the signup-confirmation path around account creation. That included auth-screen behavior fixes, a shorter guided-setup timer for quick verification, a minimal confirmation landing page for VPS deployment, Caddy routing, and a less boilerplate-looking confirmation email template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #GeneralSessionModel
|
||||||
|
Changed the local timer model from a sprint-specific structure into a more general session structure:
|
||||||
|
- added `SessionType` in `lib/types.ts`
|
||||||
|
- introduced the session types:
|
||||||
|
- `focus`
|
||||||
|
- `short_break`
|
||||||
|
- `long_break`
|
||||||
|
- replaced the old `ActiveSprint` shape with `ActiveSession` in `lib/asyncStorage.ts`
|
||||||
|
- stored `sessionType` together with `sessionId`, `taskId`, `durationSeconds`, and `endTime`
|
||||||
|
|
||||||
|
This means the active timer is no longer assumed to always be a task-linked focus sprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerSessionStartAndRestore
|
||||||
|
Updated the timer screen so it can start and restore different session types:
|
||||||
|
- replaced sprint-specific storage calls with `GetActiveSession(...)`, `SaveActiveSession(...)`, and `RemoveActiveSession(...)`
|
||||||
|
- generalized the timer start path into `startSession(...)`
|
||||||
|
- passed `p_session_type` into the Supabase `start_sprint_session(...)` RPC
|
||||||
|
- kept task linkage only for `focus` sessions
|
||||||
|
- updated the restore logic so a focus session restores by `tId`, while break sessions restore by `sessionType`
|
||||||
|
|
||||||
|
This gives the existing timer screen enough information to behave differently for focus sessions and break sessions without creating a second timer screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardAndTaskIntegration
|
||||||
|
Updated the surrounding screens so they understand the new active-session shape:
|
||||||
|
- updated `app/task/viewDetailsTask.tsx` to read the new active session model
|
||||||
|
- updated `app/(tabs)/index.tsx` so the dashboard card can describe either a focus session or a break session
|
||||||
|
- made the dashboard open the timer with either a task id or a break-session configuration, depending on what is active
|
||||||
|
|
||||||
|
This keeps the rest of the app aligned with the timer change, instead of leaving the new session model isolated to one file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardProgressAndHistory
|
||||||
|
Extended the dashboard so it works more clearly as a study-activity overview:
|
||||||
|
- added a compact `Study progress` summary near the top of the dashboard
|
||||||
|
- showed:
|
||||||
|
- `Focus sessions today`
|
||||||
|
- `Minutes today`
|
||||||
|
- `Minutes this week`
|
||||||
|
- loaded the summary from `sprint_sessions` instead of from planning data
|
||||||
|
- added a `Recent sessions` section showing:
|
||||||
|
- task title when available
|
||||||
|
- session type
|
||||||
|
- duration
|
||||||
|
- final status
|
||||||
|
- date and time
|
||||||
|
- added a small `Recently completed tasks` section based on recent task completion updates
|
||||||
|
|
||||||
|
This moved the dashboard closer to the vision requirement that progress should reflect actual study behavior rather than only task structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardLayoutRestructure
|
||||||
|
Reworked the order of the dashboard sections so the screen reads more clearly as a home surface:
|
||||||
|
- kept the active-session card at the top when relevant
|
||||||
|
- placed `Study progress` before the task lists
|
||||||
|
- moved `Tasks with upcoming deadlines` directly under the progress summary
|
||||||
|
- pushed `Recent sessions` and `Recently completed tasks` lower as secondary context
|
||||||
|
- made the lower history area work as a side-by-side layout when screen width allows it
|
||||||
|
- changed the dashboard body to a scrollable layout so the extra sections still fit without clipping
|
||||||
|
|
||||||
|
The result is a dashboard that moves from orientation, to next action, to history instead of feeling like a stacked report page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ConsistentProgressModel
|
||||||
|
Aligned the progress language across the detail screens so each layer measures one clear thing:
|
||||||
|
- on the subject details screen, changed the progress label from `Assignment Progress` to `Assignments completed`
|
||||||
|
- added helper text clarifying that subject progress is based only on completed assignments
|
||||||
|
- on the assignment details screen, changed the progress label from `Task Progress` to `Tasks completed`
|
||||||
|
- added helper text clarifying that assignment progress is based only on completed tasks
|
||||||
|
- on the task details screen, separated completion state from study activity
|
||||||
|
- added a dedicated `Study activity` block showing:
|
||||||
|
- tracked focus time from `tasks.totalTimeInSeconds`
|
||||||
|
- completed focus-session count from `sprint_sessions`
|
||||||
|
- added an explicit task status label so completion state is not confused with study effort
|
||||||
|
|
||||||
|
This made the meaning of progress more consistent:
|
||||||
|
- `Subject` now reads as assignment completion
|
||||||
|
- `Assignment` now reads as task completion
|
||||||
|
- `Task` now reads as study effort plus completion state
|
||||||
|
- `Dashboard` now reads as recent study activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #FirstTimeSetupAndEmptyStates
|
||||||
|
Added the first guided setup flow so new users are pushed into one clear study path instead of landing in an empty app:
|
||||||
|
- added a dedicated `app/setup.tsx` route for first-time setup
|
||||||
|
- changed signup so a newly authenticated user is routed to setup instead of directly to the dashboard
|
||||||
|
- built the setup flow as a strict sequence:
|
||||||
|
- create first subject
|
||||||
|
- create first assignment
|
||||||
|
- create first task
|
||||||
|
- start first sprint
|
||||||
|
- updated the subject, assignment, and task creation screens so they can advance automatically to the next setup step
|
||||||
|
- removed the setup-breaking success popups between those guided creation steps
|
||||||
|
- added short auth-screen explanations describing:
|
||||||
|
- what the app does
|
||||||
|
- why an account exists
|
||||||
|
- that study structure and progress follow the user
|
||||||
|
- added clearer empty states on the dashboard and subjects screen that point the user into guided setup
|
||||||
|
- tightened the empty-state copy on subject and assignment details so each one points toward the next required object in the hierarchy
|
||||||
|
|
||||||
|
This closes a large part of the first-run friction gap without introducing a separate onboarding system or broader navigation rewrite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #AuthScreenKeyboardHandling
|
||||||
|
Adjusted the auth screens so text inputs do not stay buried behind the on-screen keyboard:
|
||||||
|
- updated `app/login.tsx` so the login content scrolls and shifts upward when the keyboard opens
|
||||||
|
- updated `app/createUser.tsx` so the entire create-account content block lifts upward with the keyboard instead of only trying to scroll one input into view
|
||||||
|
- kept the changes local to the auth screens instead of introducing a broader shared keyboard abstraction
|
||||||
|
|
||||||
|
This was aimed specifically at the real usability problem where the password field could end up hidden during login or signup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #SignupNavigationAndHeaderAlignment
|
||||||
|
Adjusted the signup screen navigation so it matches the rest of the app more closely:
|
||||||
|
- removed the temporary in-screen back button experiment from the signup page
|
||||||
|
- re-enabled the normal stack header for `createUser` in `app/_layout.tsx`
|
||||||
|
- kept signup navigation on the default app-style back arrow instead of a one-off local control
|
||||||
|
|
||||||
|
This kept the auth flow visually more consistent with the rest of the route stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #GuidedSetupFiveSecondSprint
|
||||||
|
Changed guided setup so the first sprint can be tested almost immediately:
|
||||||
|
- updated `app/setup.tsx` so the setup flow opens the timer with a fixed `5` second duration
|
||||||
|
- extended `app/task/timer.tsx` so it can also accept an explicit `durationSeconds` route param
|
||||||
|
- kept the rest of the timer behavior unchanged, so the setup-specific shortcut still runs through the same session start, storage, and completion flow as normal timers
|
||||||
|
|
||||||
|
This made the first-run path quicker to test without changing the broader timer model back to a special-case setup implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #SignupConfirmationDeployment
|
||||||
|
Built the first deployable confirmation landing page outside the Expo app:
|
||||||
|
- added `deploy/signup-confirmation/site/index.html` as a minimal static confirmation page
|
||||||
|
- added `deploy/signup-confirmation/docker-compose.yml` so the page can be served with `nginx:alpine`
|
||||||
|
- added a small README for VPS deployment notes and port mapping
|
||||||
|
- verified the page deployment path together with the external VPS/domain setup already in use
|
||||||
|
|
||||||
|
This created a concrete destination URL for signup confirmation emails instead of leaving the email to resolve into a blank or undefined endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CaddyAndEmailConfirmationPolish
|
||||||
|
Finished the external confirmation flow around signup:
|
||||||
|
- corrected the Caddy reverse-proxy target from container port `8080` to `80` for the `nginx` confirmation container
|
||||||
|
- confirmed that the confirmation page then resolved correctly behind the existing Caddy-plus-Docker setup
|
||||||
|
- replaced the original bare confirmation email body with a cleaner branded HTML email using the existing `{{ .ConfirmationURL }}` placeholder
|
||||||
|
|
||||||
|
This moved the signup confirmation flow from a functional but rough setup into something that is both deployable and presentable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #PostSessionBreakFlow
|
||||||
|
Added the first real post-session flow in the timer UI:
|
||||||
|
- after a completed focus session, the timer now shows:
|
||||||
|
- `Start short break`
|
||||||
|
- `Skip break`
|
||||||
|
- starting the break reopens the same timer route in `short_break` mode
|
||||||
|
- after a completed short break, the timer now shows:
|
||||||
|
- `Continue with same task`
|
||||||
|
- `Back to dashboard`
|
||||||
|
- passed `returnTaskId` through the route so the timer can return the user to the original task after the break
|
||||||
|
|
||||||
|
This is the first implementation of an actual study loop rather than a timer that simply ends and disappears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #BreakTimerPresentation
|
||||||
|
Adjusted the timer UI so break sessions read more clearly:
|
||||||
|
- added a fixed-duration block for break sessions instead of showing the normal duration picker
|
||||||
|
- used a fixed 5-minute short-break duration for the first implementation
|
||||||
|
- kept the focus-session picker unchanged
|
||||||
|
- made the break start button match the existing `Start Sprint` button styling, but show only `Start`
|
||||||
|
- removed the bug where picker or pre-start break elements remained visible on top of the running break session
|
||||||
|
|
||||||
|
This keeps the first break flow minimal and visually consistent with the existing timer screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #SupabaseFunctionAlignment
|
||||||
|
Adjusted the Supabase side so the new app flow could actually run:
|
||||||
|
- updated `start_sprint_session(...)` to accept `p_session_type`
|
||||||
|
- allowed break sessions to start with `taskId = null`
|
||||||
|
- aligned the SQL with the real table schema using:
|
||||||
|
- `sessionId`
|
||||||
|
- `taskId`
|
||||||
|
- `userId`
|
||||||
|
- `sessionType`
|
||||||
|
- `countedIntoTaskTotal`
|
||||||
|
- corrected function-return behavior so the app receives the created session id in the shape it expects
|
||||||
|
- kept finalize logic so only `focus` sessions contribute to `tasks.totalTimeInSeconds`
|
||||||
|
|
||||||
|
Without this database alignment, the app-side session model would compile but still fail when starting real sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ProblemsAndSetbacks
|
||||||
|
|
||||||
|
### #SchemaMismatch
|
||||||
|
The main blocker today was that the first SQL version assumed table columns that did not exist in the real Supabase schema.
|
||||||
|
|
||||||
|
The actual `sprint_sessions` table already contained:
|
||||||
|
- `sessionId`
|
||||||
|
- `taskId`
|
||||||
|
- `userId`
|
||||||
|
- `plannedDuration`
|
||||||
|
- `startedAt`
|
||||||
|
- `endedAt`
|
||||||
|
- `elapsedSeconds`
|
||||||
|
- `status`
|
||||||
|
- `countedIntoTaskTotal`
|
||||||
|
- `sessionType`
|
||||||
|
|
||||||
|
But it did not contain `createdAt` or `updatedAt`, so the first function version failed at runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #FunctionReturnShape
|
||||||
|
Another blocker was the shape of the return value from `start_sprint_session(...)`.
|
||||||
|
|
||||||
|
Even after the insert worked, the app still showed:
|
||||||
|
- `Session could not be created.`
|
||||||
|
|
||||||
|
The issue was not the insert itself, but that the returned value shape did not match what `getSessionId(...)` was looking for on the app side.
|
||||||
|
|
||||||
|
This had to be corrected so the RPC returned the created session id in a directly readable object shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #PauseUIScreenOverlap
|
||||||
|
The first version of the break UI had presentation bugs:
|
||||||
|
- the pause start button text looked cramped and awkward
|
||||||
|
- pre-start pause UI stayed visible after the break actually started
|
||||||
|
- picker or fixed-duration elements overlapped the running break session
|
||||||
|
|
||||||
|
This was corrected by hiding pre-start break UI while the timer is running and by reverting the pause start button back to the same visual model as the existing sprint start button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ConfirmationRoutePortMismatch
|
||||||
|
The external signup-confirmation deployment initially failed behind Caddy with `HTTP ERROR 502`.
|
||||||
|
|
||||||
|
The actual issue was not the Docker network arrangement itself, but that the reverse proxy was targeting `signup-confirmation:8080` even though the `nginx` container listens internally on port `80`.
|
||||||
|
|
||||||
|
Changing the upstream target to the real container port fixed the route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
The timer flow now goes further than the previous sprint-only model.
|
||||||
|
|
||||||
|
The app now supports:
|
||||||
|
- starting a `focus` session tied to a task
|
||||||
|
- starting a `short_break` session with no task linkage
|
||||||
|
- storing and restoring the active session with its type
|
||||||
|
- showing a post-focus decision between taking a break or skipping it
|
||||||
|
- returning from a completed short break into the same task flow or back to the dashboard
|
||||||
|
- keeping break sessions out of task time totals
|
||||||
|
|
||||||
|
At this point, the app has the first working version of the focus-and-break loop described in the vision plan, even though the cycle logic and long-break offer are not implemented yet.
|
||||||
|
|
||||||
|
The dashboard also now gives a clearer answer to:
|
||||||
|
- `What have I done today?`
|
||||||
|
- `What should I work on next?`
|
||||||
|
|
||||||
|
And the detail screens now separate planning completion from study activity more explicitly, which makes the app easier to read without having to infer what each progress bar means.
|
||||||
|
|
||||||
|
For a brand-new user, the app also no longer drops straight into a generic empty state after account creation. There is now a clearer route from signup to:
|
||||||
|
- first subject
|
||||||
|
- first assignment
|
||||||
|
- first task
|
||||||
|
- first sprint
|
||||||
|
|
||||||
|
That makes the hierarchy feel more guided and less like a blank structure the user has to interpret alone.
|
||||||
|
|
||||||
|
The signup path also now has a more complete confirmation loop around it:
|
||||||
|
- the auth screens behave more safely when the mobile keyboard opens
|
||||||
|
- guided setup can launch a very short first sprint for fast verification
|
||||||
|
- the confirmation email can point to a real public landing page
|
||||||
|
- that landing page has a working Docker/Caddy deployment path on the VPS
|
||||||
|
- the email itself no longer looks like a raw boilerplate template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
During today's work, the following behaviors were verified through implementation checks and runtime iteration:
|
||||||
|
- the new session model compiles across timer, dashboard, task details, and local storage
|
||||||
|
- `start_sprint_session(...)` now succeeds after the Supabase function updates
|
||||||
|
- the timer can start using the new session-based flow
|
||||||
|
- break sessions no longer leave the picker or fixed-duration setup visible on top of the running timer
|
||||||
|
- the dashboard compiles with the new progress-summary, recent-session, and recent-completion sections
|
||||||
|
- the task details screen compiles with a new `sprint_sessions`-based completed-session count
|
||||||
|
- the subject and assignment detail screens now label completion metrics more explicitly
|
||||||
|
- the new guided setup route compiles and links correctly with the subject, assignment, and task creation flow
|
||||||
|
- the login and signup screens compile after the keyboard-handling adjustments
|
||||||
|
- the guided setup route now opens the timer with an explicit 5-second fixed duration
|
||||||
|
- the deployable signup-confirmation page was brought up behind the VPS Caddy setup after correcting the upstream container port from `8080` to `80`
|
||||||
|
- the confirmation email template was updated to a cleaner HTML version while keeping `{{ .ConfirmationURL }}` as the actual confirmation link placeholder
|
||||||
|
|
||||||
|
Static verification also passed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npm run lint
|
||||||
|
exited with existing warning only in:
|
||||||
|
- app/task/timer.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
I did not run a live interactive app test for the later dashboard and progress-model changes. That part of the verification is static rather than runtime-confirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #FilesChanged
|
||||||
|
|
||||||
|
Main app files worked on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/task/timer.tsx
|
||||||
|
app/task/viewDetailsTask.tsx
|
||||||
|
app/(tabs)/index.tsx
|
||||||
|
app/(tabs)/subjects.tsx
|
||||||
|
app/setup.tsx
|
||||||
|
app/subject/viewDetailsSubject.tsx
|
||||||
|
app/subject/upsertSubject.tsx
|
||||||
|
app/assignment/viewDetailsAssignment.tsx
|
||||||
|
app/assignment/upsertAssignment.tsx
|
||||||
|
app/task/upsertTask.tsx
|
||||||
|
app/createUser.tsx
|
||||||
|
app/login.tsx
|
||||||
|
app/_layout.tsx
|
||||||
|
lib/asyncStorage.ts
|
||||||
|
lib/types.ts
|
||||||
|
deploy/signup-confirmation/docker-compose.yml
|
||||||
|
deploy/signup-confirmation/site/index.html
|
||||||
|
deploy/signup-confirmation/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
New note added:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notes/work-report-timer-2026-05-03.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Conclusion
|
||||||
|
|
||||||
|
The main result today was not just a timer change, but a broader step toward closing the remaining vision gaps around study flow and progress clarity.
|
||||||
|
|
||||||
|
The app now has:
|
||||||
|
- a session model that can represent both focused work and breaks
|
||||||
|
- the first concrete `focus -> break -> continue` path from the vision plan
|
||||||
|
- a dashboard that reflects recent study effort more directly
|
||||||
|
- detail screens that use more explicit and consistent progress meanings
|
||||||
|
- a first guided onboarding path that leads a new user from signup to their first workable sprint path
|
||||||
|
- more usable auth screens when entering credentials on mobile
|
||||||
|
- a complete basic signup-confirmation flow that now reaches a real deployed landing page and a cleaner confirmation email
|
||||||
|
|
||||||
|
The remaining work in this area is now less about inventing the model from scratch and more about extending, polishing, and live-validating the pieces that are already in place.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Main Flow Tightening and Timer Duration Picker Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today's work focused on the next concrete step in the vision-gap plan after the already completed sections.
|
||||||
|
|
||||||
|
The main goal was to reduce friction in the path from choosing work to actually starting a focus session. That meant tightening the task-level and dashboard-level sprint actions, introducing a consistent default focus duration, and making the timer screen feel faster to enter without removing the older duration-picker path entirely.
|
||||||
|
|
||||||
|
Later in the same work session, the scope narrowed further into the timer screen itself because the reintroduced picker flow behaved incorrectly. That led to a smaller follow-up fix focused specifically on stabilizing the picker state and preventing the screen from resetting while the user scrolls.
|
||||||
|
|
||||||
|
The scope also expanded into the help-flow modal on the dashboard and subjects screens so its explanation of the app structure matches the way the app now actually works.
|
||||||
|
|
||||||
|
After that, one more timer-flow bug surfaced in the post-session overlay itself. A completed break could still reuse the focus-session action menu, which incorrectly offered the user another break instead of only the actions that make sense after a break has ended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #DefaultFocusDuration
|
||||||
|
Introduced a shared default session-duration source for the low-friction focus flow:
|
||||||
|
- added `lib/sessionDefaults.ts`
|
||||||
|
- defined:
|
||||||
|
- `DEFAULT_FOCUS_DURATION_MINUTES`
|
||||||
|
- `DEFAULT_SHORT_BREAK_DURATION_MINUTES`
|
||||||
|
- reused those constants across the timer, task details, and dashboard paths
|
||||||
|
|
||||||
|
This removed the need to hardcode the same default duration in multiple places and made the main sprint path more consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TaskDetailsPrimarySprintAction
|
||||||
|
Updated the task details screen so `Start Sprint` is the strongest action on the page:
|
||||||
|
- moved `Start Sprint` out of the lower row of equal-weight controls
|
||||||
|
- made it the primary full-width action above `Edit` and `Delete`
|
||||||
|
- added small helper text clarifying that the action starts a `25` minute focus sprint
|
||||||
|
- updated the task-details start flow so it passes the default focus duration into the timer route
|
||||||
|
- tightened the active-session replacement alert text so it clearly states what will happen before the current session is replaced
|
||||||
|
|
||||||
|
This makes the task screen push the user more directly toward real study work instead of presenting sprint start as only one option among several management actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DashboardDirectSprintStart
|
||||||
|
Reduced dashboard-to-timer friction for upcoming tasks:
|
||||||
|
- added a `Start Sprint` action directly on the `Tasks with upcoming deadlines` cards
|
||||||
|
- made that action open the timer immediately with the shared default focus duration
|
||||||
|
- handled the three relevant states:
|
||||||
|
- no active session
|
||||||
|
- an expired stored session
|
||||||
|
- an already running different session that must be explicitly replaced
|
||||||
|
- renamed the active-session dashboard button from `Open Session` to:
|
||||||
|
- `Resume Sprint`
|
||||||
|
- `Resume Break`
|
||||||
|
|
||||||
|
This removed one unnecessary detour where the user had to open task details first before reaching the timer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerDefaultDurationFlow
|
||||||
|
Changed the timer entry flow so focus sessions no longer force the user through duration selection before they can begin:
|
||||||
|
- changed the default focus-session setup to show a fixed default duration first
|
||||||
|
- kept break sessions on a fixed-duration path as before
|
||||||
|
- made the start action use the default focus duration immediately unless the user actively chooses a custom one
|
||||||
|
|
||||||
|
This better matches the low-friction part of the vision plan, where starting work should feel immediate rather than configuration-heavy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CustomDurationReturnPath
|
||||||
|
Reintroduced the old duration-picker flow as an explicit optional side path instead of the default:
|
||||||
|
- added a `Choose a different duration` button on the pre-start focus timer screen
|
||||||
|
- reopened the old picker presentation only when the route enters an explicit picker mode
|
||||||
|
|
||||||
|
This keeps the faster default path while still preserving the older manual-duration interaction for users who want it, without adding a second reversal action inside the picker itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #PostSessionActionClarity
|
||||||
|
Adjusted the timer completion overlay so the focus-session exit path is more explicit:
|
||||||
|
- after a completed focus session, the overlay now offers:
|
||||||
|
- `Start short break`
|
||||||
|
- `Continue same task`
|
||||||
|
- `Back to dashboard`
|
||||||
|
- updated the explanation text so the available next actions are described directly in the overlay copy
|
||||||
|
|
||||||
|
This makes the post-session decision path closer to the plan's requirement that break, continue, and dashboard-return actions should be simple and explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerPickerGlitchFix
|
||||||
|
Fixed the first version of the restored duration picker after it showed unstable behavior:
|
||||||
|
- the picker numbers could initially appear blank until the list was scrolled
|
||||||
|
- the selected duration could snap back incorrectly when scrolling ended
|
||||||
|
- the cause was that the picker route was being rewritten while the user interacted with the list
|
||||||
|
- changed picker selection to use local component state instead of route replacement on every scroll stop
|
||||||
|
- added explicit initial offset restoration on picker open so the visible selection matches the current duration immediately
|
||||||
|
- kept the route change only for entering or leaving picker mode, not for every intermediate selection
|
||||||
|
|
||||||
|
This made the picker usable again without undoing the lower-friction default entry flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #HelpFlowAlignment
|
||||||
|
Updated the help modal so it matches the current app structure more closely:
|
||||||
|
- kept the main hierarchy as:
|
||||||
|
- `Subject`
|
||||||
|
- `Assignment`
|
||||||
|
- `Task`
|
||||||
|
- `Sprint`
|
||||||
|
- updated the `Sprint` explanation so it now reflects the real post-session flow:
|
||||||
|
- take a break
|
||||||
|
- continue the same task
|
||||||
|
- return to the dashboard
|
||||||
|
- changed the supporting copy so it explains that the work path now leads into both sprints and breaks instead of only into one focused work session
|
||||||
|
- added quick-map text clarifying the dashboard's current role:
|
||||||
|
- resume active session
|
||||||
|
- start next sprint
|
||||||
|
- review recent progress
|
||||||
|
- changed the help CTA on the dashboard from `Start with Subjects` to `Open Subjects`
|
||||||
|
- changed the help CTA on the subjects screen from `Start with Subjects` to `Close Guide`
|
||||||
|
|
||||||
|
This keeps the help flow aligned with the app's actual current behavior instead of leaving it stuck on an older sprint-only interpretation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #PostBreakMenuFix
|
||||||
|
Fixed a timer completion bug where a finished break could still produce the same action menu as a finished focus session:
|
||||||
|
- the post-session overlay had to know which session type actually just ended
|
||||||
|
- the previous flow could fall back to local screen state instead of the persisted active session
|
||||||
|
- this caused a break completion to sometimes be treated like a focus completion
|
||||||
|
- changed the completion flow so it reads the stored active session before building the post-session prompt
|
||||||
|
- reused that same session snapshot when finalizing the session in Supabase
|
||||||
|
|
||||||
|
This means the overlay now behaves correctly after a break finishes:
|
||||||
|
- it does not offer `Start short break` again
|
||||||
|
- it instead keeps the narrower break-finished path:
|
||||||
|
- continue with the same task
|
||||||
|
- go back to the dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ProblemsAndSetbacks
|
||||||
|
|
||||||
|
### #PickerStateReset
|
||||||
|
The main issue during this work happened after the older picker screen was reintroduced as an optional path.
|
||||||
|
|
||||||
|
The first implementation reopened the picker route correctly, but it also updated the route params again when scrolling stopped. In practice this caused two visible problems:
|
||||||
|
- the initial number presentation was unstable
|
||||||
|
- the selected value could reset unexpectedly after momentum ended
|
||||||
|
|
||||||
|
The fix was to keep picker selection local to `app/task/timer.tsx` while the picker is open, and only use route params to decide whether the picker mode should be shown in the first place.
|
||||||
|
|
||||||
|
### #PostSessionTypeMismatch
|
||||||
|
Another issue appeared after the post-session focus actions were introduced.
|
||||||
|
|
||||||
|
The completion overlay already had separate UI for `focus` and `break` sessions, but the value used to choose between them was not robust enough. In practice, that made it possible for a finished break to reopen the focus-style menu and incorrectly offer another break.
|
||||||
|
|
||||||
|
The fix was to derive the completed session type from the persisted active session that had actually been running, rather than relying only on the screen's local state at the moment the animation finished.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
The timer/task/dashboard flow now does more to push the user into focused work with fewer unnecessary steps.
|
||||||
|
|
||||||
|
The app now supports:
|
||||||
|
- a shared default focus duration for the main sprint path
|
||||||
|
- a stronger `Start Sprint` action on the task details screen
|
||||||
|
- direct sprint start from dashboard upcoming-task cards
|
||||||
|
- clearer `Resume Sprint` and `Resume Break` wording on the dashboard
|
||||||
|
- a fixed default-duration entry state on the timer screen
|
||||||
|
- an optional custom-duration picker path instead of a forced picker
|
||||||
|
- explicit post-focus next actions for break, continue, or dashboard return
|
||||||
|
- a stable picker implementation that keeps its selected value while the user scrolls
|
||||||
|
- a corrected break-finished overlay that no longer offers another pause when the completed session was already a break
|
||||||
|
- a help-flow explanation that now matches the real sprint, break, dashboard, and subjects flow more closely
|
||||||
|
|
||||||
|
At this point, the timer flow is more aligned with the vision requirement that starting work should feel fast, focused, and low-friction rather than like a chain of setup steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
Static checks were run after the implementation work and after the picker bug fix:
|
||||||
|
|
||||||
|
An additional static check was also run after the post-break menu fix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Session Reliability and Break-Cycle Work Report
|
||||||
|
|
||||||
|
## #Overview
|
||||||
|
Today's work continued from the updated vision-gap plan, with the focus narrowed to the remaining timer and session-state gaps rather than broader feature expansion.
|
||||||
|
|
||||||
|
The first goal was to finish the last missing part of the focus-and-break loop by making the app distinguish between a short break and a long break in a way that matches an actual study cycle instead of using total historical session count.
|
||||||
|
|
||||||
|
After that, the scope shifted into reliability work because the remaining highest-risk issue was not missing UI, but the possibility that local active-session state and recorded session history could drift apart. That led to a review of how the dashboard, setup flow, task details screen, and timer screen each handled expired, cancelled, or replaced sessions.
|
||||||
|
|
||||||
|
Later in the same work session, the focus narrowed again into wording and flow polish on the timer screen. The break and sprint descriptions were rewritten so they better reflect the app's goal of supporting structured study behavior, and two runtime regressions reported after testing were fixed in the timer flow itself.
|
||||||
|
|
||||||
|
After that, the work shifted into the remaining first-time-user gap from the vision plan. The login and tab flows were tightened so incomplete users are routed into guided setup automatically, and the first guided sprint was changed into a short onboarding demo instead of dropping a new user straight into a normal 25-minute timer.
|
||||||
|
|
||||||
|
The final pass of the day was smaller, but still tied to the same product goal. The help modals on the dashboard and subjects screens were rewritten so they explain the focus-session and break rhythm in a more human way instead of sounding like a rigid step list.
|
||||||
|
|
||||||
|
After that, one final navigation-polish pass was added on the tabs layout itself. The bottom tabs were given explicit icons so the app's primary navigation reads faster at a glance and feels less unfinished.
|
||||||
|
|
||||||
|
After the branch work was merged back into `main`, one more cleanup pass was needed. That merge left multiple screen files in a syntactically broken state, so the final work shifted away from feature work and into restoring the current main branch to a usable state before report-focused delivery work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #LongBreakCycleCompletion
|
||||||
|
Finished the missing long-break part of the focus-and-break loop:
|
||||||
|
- extended the shared session defaults in `lib/sessionDefaults.ts`
|
||||||
|
- added:
|
||||||
|
- `DEFAULT_LONG_BREAK_DURATION_MINUTES`
|
||||||
|
- `FOCUS_SESSIONS_PER_LONG_BREAK`
|
||||||
|
- `STUDY_CYCLE_IDLE_RESET_MINUTES`
|
||||||
|
- updated the timer flow so the next break is chosen from a local study-cycle model instead of total historical session count
|
||||||
|
|
||||||
|
The long-break rule now follows a small continuous-cycle interpretation of study flow rather than incorrectly counting unrelated sessions from earlier in the day or from previous days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #StudyCycleState
|
||||||
|
Added a small local study-cycle state to support the new break behavior:
|
||||||
|
- extended `lib/asyncStorage.ts`
|
||||||
|
- added `StudyCycle`
|
||||||
|
- added:
|
||||||
|
- `SaveStudyCycle`
|
||||||
|
- `GetStudyCycle`
|
||||||
|
- `RemoveStudyCycle`
|
||||||
|
- tracked:
|
||||||
|
- the task tied to the current cycle
|
||||||
|
- how many focus sessions have been completed in that cycle
|
||||||
|
- the last completed session type
|
||||||
|
- the last completion timestamp
|
||||||
|
|
||||||
|
This keeps the long-break offer tied to the current study run instead of to the user's entire database history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DynamicBreakPrompt
|
||||||
|
Updated the timer completion overlay so post-focus actions are based on the actual cycle state:
|
||||||
|
- expanded the post-session prompt model to include the next break type
|
||||||
|
- replaced the hardcoded short-break action with a dynamic break action
|
||||||
|
- updated the overlay text and button label so the user now sees:
|
||||||
|
- `Start short break`
|
||||||
|
- or `Start long break`
|
||||||
|
depending on the current cycle
|
||||||
|
|
||||||
|
This completes the missing loop from the plan where break behavior should feel intentional rather than unfinished.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #SessionLifecycleConsistency
|
||||||
|
Introduced a shared finalization path for active sessions:
|
||||||
|
- added `lib/sessionLifecycle.ts`
|
||||||
|
- introduced `finalizeStoredSession(...)`
|
||||||
|
- moved repeated session-finalization behavior into one place:
|
||||||
|
- remove local active-session state
|
||||||
|
- clear study-cycle state when the final status is not `completed`
|
||||||
|
- finalize the same `sessionId` in Supabase with:
|
||||||
|
- `completed`
|
||||||
|
- `cancelled`
|
||||||
|
- `expired`
|
||||||
|
|
||||||
|
This reduced the risk that one screen would only clear local storage while another screen properly finalized the database record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #CrossScreenReliabilityFixes
|
||||||
|
Applied the shared finalization path across the screens that handle active-session recovery or replacement:
|
||||||
|
- `app/task/timer.tsx`
|
||||||
|
- `app/(tabs)/index.tsx`
|
||||||
|
- `app/task/viewDetailsTask.tsx`
|
||||||
|
- `app/setup.tsx`
|
||||||
|
|
||||||
|
The updated paths now explicitly finalize sessions when they are:
|
||||||
|
- expired on reopen
|
||||||
|
- expired while being observed from the dashboard
|
||||||
|
- cancelled because the user replaces one active sprint with another
|
||||||
|
|
||||||
|
This closes a real reliability gap where the app could previously lose the active local session while leaving the recorded session in the database unfinalized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TimerAndBreakCopyPolish
|
||||||
|
Rewrote the timer and break descriptions so they better match the product's intended tone:
|
||||||
|
- updated the pre-start sprint description
|
||||||
|
- updated the pre-start break description
|
||||||
|
- updated the focus fallback description on the running timer
|
||||||
|
- updated the post-focus and post-break explanation copy
|
||||||
|
|
||||||
|
The new wording emphasizes that structured focus and intentional breaks matter for studying, instead of sounding like placeholder or utility-only text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #ReportedRegressionFixes
|
||||||
|
Fixed two runtime issues discovered during manual testing after the earlier session-cycle changes:
|
||||||
|
- after cancelling a focus session, the sprint-duration view could appear visually blank until the user manually dragged the picker
|
||||||
|
- after completing a long break, `Continue with same task` could route the user back to the dashboard instead of returning to the correct task flow
|
||||||
|
|
||||||
|
The fixes were:
|
||||||
|
- reinitializing the picker-offset path when the timer returns to a non-running state
|
||||||
|
- preserving `returnTaskId` inside the stored active-session shape so break sessions keep the correct task context all the way through completion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #OnboardingRoutingGuard
|
||||||
|
Closed the remaining onboarding-routing gap so incomplete users are pushed into guided setup instead of being left in the dashboard tabs:
|
||||||
|
- added `lib/setupStatus.ts`
|
||||||
|
- moved the shared setup-completion rule into one place
|
||||||
|
- updated:
|
||||||
|
- `app/login.tsx`
|
||||||
|
- `app/(tabs)/_layout.tsx`
|
||||||
|
- `app/(tabs)/index.tsx`
|
||||||
|
- `app/(tabs)/subjects.tsx`
|
||||||
|
- setup completion is now checked from the same source in login, tab entry, dashboard, and subjects
|
||||||
|
|
||||||
|
This made the setup flow enforceable instead of depending on the user noticing the guided-setup card in the dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #FirstSprintDemoFlow
|
||||||
|
Adjusted the first guided sprint so the first-time experience better matches the low-friction vision goal:
|
||||||
|
- extended `lib/asyncStorage.ts`
|
||||||
|
- added:
|
||||||
|
- `GetSetupSprintDemoUsed`
|
||||||
|
- `SaveSetupSprintDemoUsed`
|
||||||
|
- updated `app/task/upsertTask.tsx` and `app/setup.tsx` so the first setup sprint uses:
|
||||||
|
- `durationSeconds: '5'`
|
||||||
|
- `onboardingDemo: 'true'`
|
||||||
|
- updated `app/task/timer.tsx` so that onboarding-demo sprint completion:
|
||||||
|
- skips the normal session-complete modal
|
||||||
|
- routes directly to the dashboard
|
||||||
|
|
||||||
|
This keeps the first sprint short enough to demonstrate the flow without locking a new user into a full focus block, while still falling back to the normal focus-session duration after the demo has been used once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #HelpModalFlowCopy
|
||||||
|
Updated the help modals on the dashboard and subjects screens so they explain the intended study rhythm more naturally:
|
||||||
|
- updated:
|
||||||
|
- `app/(tabs)/index.tsx`
|
||||||
|
- `app/(tabs)/subjects.tsx`
|
||||||
|
- rewrote the flow-step descriptions so they feel less mechanical
|
||||||
|
- added clearer wording about the actual intended loop:
|
||||||
|
- focus session
|
||||||
|
- short pause
|
||||||
|
- focus session again
|
||||||
|
- longer pause after a few rounds
|
||||||
|
|
||||||
|
This better matches the app's tone and makes the focus/break cycle easier to understand from inside the product itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #TabBarIconPolish
|
||||||
|
Added explicit icons to the bottom-tab navigation so the two primary surfaces are easier to scan:
|
||||||
|
- updated `app/(tabs)/_layout.tsx`
|
||||||
|
- reused the existing `MaterialIcons` set already used elsewhere in the app
|
||||||
|
- assigned:
|
||||||
|
- `dashboard` to the dashboard tab
|
||||||
|
- `menu-book` to the subjects tab
|
||||||
|
|
||||||
|
This was a small UI polish pass, but it improves immediate navigation clarity and makes the tab bar feel more intentional instead of placeholder-like.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #MainBranchMergeCleanup
|
||||||
|
Repaired merge-related breakage after switching back to `main`:
|
||||||
|
- fixed `app/(tabs)/subjects.tsx`
|
||||||
|
- fixed `app/subject/viewDetailsSubject.tsx`
|
||||||
|
- rebuilt `app/task/viewDetailsTask.tsx` into a consistent working version
|
||||||
|
|
||||||
|
The merge had left these files with duplicated blocks, broken hook structure, and invalid JSX. The cleanup work focused on restoring the intended screen behavior rather than changing product scope.
|
||||||
|
|
||||||
|
The result was:
|
||||||
|
- subjects screen logic restored to a valid loading/setup/render flow
|
||||||
|
- subject details screen header and progress area reconstructed
|
||||||
|
- task details screen restored with working context, study activity, and sprint-start actions
|
||||||
|
|
||||||
|
This was not new feature work, but it was necessary delivery work because the main branch was no longer in a reliable edit/test state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ProblemsAndSetbacks
|
||||||
|
|
||||||
|
### #SessionTruthDivergence
|
||||||
|
The main reliability issue uncovered today was not in the timer animation itself, but in how different screens treated expired or replaced sessions.
|
||||||
|
|
||||||
|
Several screens could detect that a stored session was no longer valid, but some of them only removed the local active-session entry instead of also finalizing the matching `sprint_sessions` row in Supabase. That created a risk where the UI and the database could tell different stories about the same session.
|
||||||
|
|
||||||
|
The fix was to stop duplicating that logic screen by screen and route those paths through a shared finalization helper instead.
|
||||||
|
|
||||||
|
### #PostChangeRuntimeRegressions
|
||||||
|
After the cycle and reliability changes landed, manual testing surfaced two smaller regressions in the timer screen:
|
||||||
|
- the duration screen could look empty after cancelling a focus session
|
||||||
|
- the break-return flow lost its task target after a long break
|
||||||
|
|
||||||
|
These were not architectural problems, but they were both important because they affected the user's immediate understanding of the timer flow after interacting with it.
|
||||||
|
|
||||||
|
### #OnboardingFlowMismatch
|
||||||
|
Manual testing later uncovered a smaller flow mismatch inside guided setup:
|
||||||
|
- the first task created in setup could still open the timer with the normal 25-minute focus default
|
||||||
|
- returning to the guided-setup screen afterwards could then launch a different 5-second demo path
|
||||||
|
|
||||||
|
The problem was that task creation in setup and the setup screen itself were using two different timer-entry paths. The fix was to make those paths share the same one-time onboarding-demo rule.
|
||||||
|
|
||||||
|
### #PostMergeCodeBreakage
|
||||||
|
The final setback of the day came after the branch merge itself rather than from the timer/session work.
|
||||||
|
|
||||||
|
Several files on `main` were left in a partially merged state with duplicated code fragments and broken JSX structure. That meant the next pass could not start from feature verification alone, because basic app screens were no longer parseable.
|
||||||
|
|
||||||
|
The practical fix was to repair those files directly first, then re-run targeted verification on the restored app files before deciding whether any real feature regressions were still present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CurrentState
|
||||||
|
|
||||||
|
The timer and session model are now closer to the final intended behavior in the vision-gap plan.
|
||||||
|
|
||||||
|
The app now supports:
|
||||||
|
- a simple long-break rule tied to the current study cycle
|
||||||
|
- a local cycle model that avoids counting unrelated older sessions
|
||||||
|
- a post-focus overlay that correctly offers either a short break or a long break
|
||||||
|
- a shared session-finalization path used across timer, dashboard, setup, and task-details flows
|
||||||
|
- better consistency between active local session state and recorded session history
|
||||||
|
- more intentional sprint and break wording on the timer screen
|
||||||
|
- preserved task-return context across long-break completion
|
||||||
|
- corrected timer-screen recovery after cancelling a focus session
|
||||||
|
- automatic routing into guided setup for incomplete users after login and tab entry
|
||||||
|
- a one-time onboarding sprint demo that uses a 5-second timer
|
||||||
|
- direct dashboard routing after the onboarding demo completes, without the normal completion modal
|
||||||
|
- help modals that explain the study loop in a more natural way
|
||||||
|
- explicit tab icons that make dashboard and subjects easier to distinguish at a glance
|
||||||
|
- repaired `main`-branch versions of the subjects, subject-details, and task-details screens after merge corruption
|
||||||
|
|
||||||
|
At this point, the timer/session work is closer to a finished loop, and the first-time-user path is more in line with the intended product vision. The biggest remaining work is now less about feature gaps and more about making sure the final report and final app behavior stay aligned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Verification
|
||||||
|
|
||||||
|
Static checks were run after the main implementation work and again after the regression fixes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npx tsc --noEmit
|
||||||
|
exited successfully
|
||||||
|
|
||||||
|
npm run lint
|
||||||
|
exited successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual testing also confirmed most of the intended behavior from today's scope. Two regressions were found during that testing, both inside the timer flow, and both were fixed in the same work session:
|
||||||
|
- blank sprint-duration state after cancelling a focus session
|
||||||
|
- incorrect dashboard return after pressing `Continue with same task` following a long break
|
||||||
|
|
||||||
|
Later manual testing also validated the guided-setup flow after the onboarding fixes:
|
||||||
|
- incomplete users were routed into guided setup instead of landing in dashboard tabs
|
||||||
|
- the first setup sprint used the intended 5-second demo timer
|
||||||
|
- after the demo finished, the user was sent directly to the dashboard without seeing the normal session-complete modal
|
||||||
|
|
||||||
|
The final UI pass for the day was lighter and did not change behavior, but the resulting tabs-layout diff was reviewed directly and confirmed to be limited to navigation presentation:
|
||||||
|
- explicit `MaterialIcons` import in the tabs layout
|
||||||
|
- `dashboard` icon for the dashboard tab
|
||||||
|
- `menu-book` icon for the subjects tab
|
||||||
|
|
||||||
|
The final repair pass on `main` was verified separately:
|
||||||
|
- `npx eslint app/(tabs)/subjects.tsx app/subject/viewDetailsSubject.tsx app/task/viewDetailsTask.tsx`
|
||||||
|
- `git diff --check`
|
||||||
|
- no remaining merge markers were found in `app/`, `lib/`, `components/`, or `notes/`
|
||||||
|
|
||||||
|
One broader static check still failed afterwards:
|
||||||
|
- `npx tsc --noEmit`
|
||||||
|
|
||||||
|
That remaining failure was no longer caused by the repaired app screens. The reported errors were instead in the test setup under `__tests__/`, where Jest/testing-library types and modules were not currently configured in this branch.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# DevelopmentBuildGuide
|
||||||
|
|
||||||
|
This project uses an **Expo development build** for features that are not fully supported in Expo Go, such as our notification setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WhyWeUseThis
|
||||||
|
|
||||||
|
We do **not** use Expo Go for this project when testing notifications or other native features.
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Expo Go has limitations for some native modules
|
||||||
|
- `expo-notifications` on Android requires a development build for reliable testing
|
||||||
|
- a development build works like a custom Expo Go app made specifically for this project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImportantRule
|
||||||
|
|
||||||
|
You do **not** need to rebuild the APK for every code change.
|
||||||
|
|
||||||
|
### Rebuild is **not needed** for:
|
||||||
|
- changing React components
|
||||||
|
- changing screen layouts
|
||||||
|
- changing styles
|
||||||
|
- changing Supabase queries
|
||||||
|
- changing JS/TS functions
|
||||||
|
- changing form logic
|
||||||
|
- changing routing logic
|
||||||
|
- changing notification scheduling logic in JavaScript only
|
||||||
|
|
||||||
|
### Rebuild **is needed** for:
|
||||||
|
- adding a new native dependency
|
||||||
|
- removing a native dependency
|
||||||
|
- changing `app.json`
|
||||||
|
- changing Expo plugins
|
||||||
|
- changing Android/iOS permissions
|
||||||
|
- changing native notification config
|
||||||
|
- anything that affects the native app shell
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OneTimeSetup
|
||||||
|
Install EAS CLI globally if needed:
|
||||||
|
npm install -g eas-cli
|
||||||
|
|
||||||
|
Log in to Expo:
|
||||||
|
eas login
|
||||||
|
|
||||||
|
Install the Expo development client in the project:
|
||||||
|
npx expo install expo-dev-client
|
||||||
|
|
||||||
|
# BuildTheDevelopmentAPK
|
||||||
|
Run this command from the project root:
|
||||||
|
eas build --platform android --profile development
|
||||||
|
|
||||||
|
This sends the build to Expo's cloud build service.
|
||||||
|
|
||||||
|
When the build is finished:
|
||||||
|
|
||||||
|
open the build link
|
||||||
|
click Install
|
||||||
|
install the APK on your Android phone or emulator
|
||||||
|
Do not place the APK inside the project folder.
|
||||||
|
The APK is something you install on the device, not a source file.
|
||||||
|
|
||||||
|
# DailyWorkflow
|
||||||
|
After the development build APK is installed:
|
||||||
|
|
||||||
|
Start the project:
|
||||||
|
npx expo start
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Task Management Mobile Application – Project Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This project is a mobile task management application developed using **React Native (Expo)** with **Supabase** as the backend. The application enables users to create, view, edit, and delete tasks while maintaining secure authentication and session management.
|
||||||
|
|
||||||
|
The system follows a full-stack architecture, integrating frontend UI components with backend database operations and authentication services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- React Native (Expo)
|
||||||
|
- Expo Router (navigation)
|
||||||
|
- React Hooks (`useState`, `useEffect`, `useFocusEffect`)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Supabase (PostgreSQL database + authentication)
|
||||||
|
- Row Level Security (RLS) for data protection
|
||||||
|
|
||||||
|
### Storage & Security
|
||||||
|
- Expo SecureStore for persistent session storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Application Structure
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
The app uses **Expo Router** with a combination of:
|
||||||
|
- **Tab navigation** for main screens
|
||||||
|
- **Stack navigation** for individual pages
|
||||||
|
|
||||||
|
Main routes:
|
||||||
|
- `/` → Home (Today view)
|
||||||
|
- `/tasks` → Task list
|
||||||
|
- `/createTask` → Create new task
|
||||||
|
- `/editTask` → Edit existing task
|
||||||
|
- `/createUser` → Sign up
|
||||||
|
- `/login` → Login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication System
|
||||||
|
|
||||||
|
The application includes a complete authentication flow:
|
||||||
|
|
||||||
|
- **User Registration**
|
||||||
|
- Email and password-based signup
|
||||||
|
- **User Login**
|
||||||
|
- Credential-based authentication
|
||||||
|
- **Session Management**
|
||||||
|
- Persistent sessions using SecureStore
|
||||||
|
- **Protected Routes**
|
||||||
|
- Users are redirected if not authenticated
|
||||||
|
- **Logout**
|
||||||
|
- Ends session via Supabase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Management Features
|
||||||
|
|
||||||
|
### Create Task
|
||||||
|
Users can:
|
||||||
|
- Enter title, description, and deadline
|
||||||
|
- Set completion status using a checkbox
|
||||||
|
- Save tasks to the database
|
||||||
|
|
||||||
|
### Read Tasks
|
||||||
|
- Tasks are fetched from Supabase
|
||||||
|
- Displayed using a `SectionList`
|
||||||
|
- Categorized into:
|
||||||
|
- Upcoming Tasks
|
||||||
|
- Completed Tasks
|
||||||
|
- Supports manual refresh and auto-refresh on screen focus
|
||||||
|
|
||||||
|
### Update Task
|
||||||
|
- Existing tasks can be edited
|
||||||
|
- Fields are pre-filled with current values
|
||||||
|
- Updates are sent to the database using task ID
|
||||||
|
|
||||||
|
### Delete Task
|
||||||
|
- Tasks can be deleted with confirmation
|
||||||
|
- List updates after deletion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Interface Design
|
||||||
|
|
||||||
|
A centralized styling system (`defaultStyles`) was implemented to ensure consistency across the application.
|
||||||
|
|
||||||
|
### Key UI Components
|
||||||
|
- Text inputs for forms
|
||||||
|
- Buttons and pressable elements
|
||||||
|
- Custom checkbox component
|
||||||
|
- Sectioned task list
|
||||||
|
- Activity indicators for loading states
|
||||||
|
|
||||||
|
### UX Features
|
||||||
|
- Keyboard handling with `KeyboardAvoidingView`
|
||||||
|
- Tap outside to dismiss keyboard
|
||||||
|
- Alerts for feedback (success/errors)
|
||||||
|
- Dynamic headers with actions (refresh, logout)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
The app uses local state via React Hooks:
|
||||||
|
- `useState` for form data and UI state
|
||||||
|
- `useEffect` for lifecycle events
|
||||||
|
- `useFocusEffect` for refreshing data when screens are focused
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Integration
|
||||||
|
|
||||||
|
### Supabase Client
|
||||||
|
- Configured using environment variables
|
||||||
|
- Secure session persistence enabled
|
||||||
|
- Automatic token refresh
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
- `INSERT` → create tasks
|
||||||
|
- `SELECT` → fetch tasks
|
||||||
|
- `UPDATE` → edit tasks
|
||||||
|
- `DELETE` → remove tasks
|
||||||
|
|
||||||
|
### Data Model (Tasks Table)
|
||||||
|
- `tId` (UUID)
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `deadline`
|
||||||
|
- `isCompleted`
|
||||||
|
- `lastChanged`
|
||||||
|
- `uId` (user reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Row Level Security (RLS) ensures users can only access their own tasks
|
||||||
|
- Queries are filtered using `auth.uid() = uId`
|
||||||
|
- Update operations require a `WHERE` clause to prevent unintended changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Challenges and Solutions
|
||||||
|
|
||||||
|
### Routing Issues
|
||||||
|
- Fixed incorrect relative paths by using absolute routes and parameters
|
||||||
|
|
||||||
|
### Data Binding
|
||||||
|
- Ensured edit forms are pre-filled by fetching data using task ID
|
||||||
|
|
||||||
|
### Database Errors
|
||||||
|
- Resolved missing `WHERE` clause in update queries
|
||||||
|
- Handled invalid date formats
|
||||||
|
|
||||||
|
### UI Layout Problems
|
||||||
|
- Improved header layout by replacing default buttons with custom components
|
||||||
|
- Fixed spacing and alignment issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The project successfully demonstrates the development of a full-stack mobile application with:
|
||||||
|
- Secure authentication
|
||||||
|
- Persistent user sessions
|
||||||
|
- CRUD operations with a backend database
|
||||||
|
- Structured navigation and UI design
|
||||||
|
|
||||||
|
The application follows scalable patterns and provides a solid foundation for further enhancements such as improved UI design, additional features, and performance optimizations.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
## #Overview
|
||||||
|
Today I implemented a full **CRUD system** for the three core entities in the application:
|
||||||
|
- **Subjects**
|
||||||
|
- **Assignments**
|
||||||
|
- **Tasks**
|
||||||
|
|
||||||
|
This includes creating, editing, viewing details, and deleting records, as well as connecting all screens using Expo Router.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ImplementedFeatures
|
||||||
|
|
||||||
|
### #Subjects
|
||||||
|
Created full CRUD flow:
|
||||||
|
- `createSubject.tsx`
|
||||||
|
- `editSubject.tsx`
|
||||||
|
- `viewDetailsSubject.tsx`
|
||||||
|
|
||||||
|
**Functionality:**
|
||||||
|
- Create new subjects
|
||||||
|
- Edit existing subjects
|
||||||
|
- View subject details
|
||||||
|
- Delete subjects
|
||||||
|
|
||||||
|
**Relationships:**
|
||||||
|
- Subjects act as the top-level entity
|
||||||
|
- Assignments can optionally be linked to a subject via `sId`
|
||||||
|
- Subjects are displayed:
|
||||||
|
- globally (`subjects.tsx`)
|
||||||
|
- or standalone (`viewDetailsSubjects.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #Assignments
|
||||||
|
Created full CRUD flow:
|
||||||
|
- `createAssignment.tsx`
|
||||||
|
- `editAssignment.tsx`
|
||||||
|
- `viewDetailsAssignment.tsx`
|
||||||
|
|
||||||
|
**Functionality:**
|
||||||
|
- Create assignments (with optional `sId`)
|
||||||
|
- Edit assignments
|
||||||
|
- View assignment details
|
||||||
|
- Delete assignments
|
||||||
|
|
||||||
|
**Relationships:**
|
||||||
|
- Assignments can exist:
|
||||||
|
- linked to a subject (`sId`)
|
||||||
|
- or standalone (`sId = null`)
|
||||||
|
- Assignments are displayed:
|
||||||
|
- globally (`assignments.tsx`)
|
||||||
|
- or within a subject (`viewDetailsSubject.tsx`)
|
||||||
|
- or standalone (`viewDetailsAssignment.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #Tasks
|
||||||
|
Created full CRUD flow:
|
||||||
|
- `createTask.tsx`
|
||||||
|
- `editTask.tsx`
|
||||||
|
- `viewDetailsTask.tsx`
|
||||||
|
|
||||||
|
**Functionality:**
|
||||||
|
- Create tasks
|
||||||
|
- Edit tasks
|
||||||
|
- View task details
|
||||||
|
- Delete tasks
|
||||||
|
|
||||||
|
**Relationships:**
|
||||||
|
- Tasks are linked to assignments via `aId`
|
||||||
|
- Tasks are accessed through assignment detail pages
|
||||||
|
- Assignments are displayed:
|
||||||
|
- globally (`tasks.tsx`)
|
||||||
|
- or within an assignment (`viewDetailsAssignment.tsx`)
|
||||||
|
- or standalone (`viewDetailsTask.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #RoutingStructure
|
||||||
|
|
||||||
|
### #TopLevelScreens
|
||||||
|
- `subjects.tsx` → list of all subjects
|
||||||
|
- `assignments.tsx` → list of all assignments
|
||||||
|
- `tasks.tsx` → list of all tasks
|
||||||
|
- `index.tsx` → pre-existing home screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #DataModel
|
||||||
|
|
||||||
|
### #Subject
|
||||||
|
- `sId`
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `isActive`
|
||||||
|
- `lastChanged`
|
||||||
|
- `uId`
|
||||||
|
|
||||||
|
### #Assignment
|
||||||
|
- `aId`
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `deadline`
|
||||||
|
- `isCompleted`
|
||||||
|
- `lastChanged`
|
||||||
|
- `uId`
|
||||||
|
- `sId`
|
||||||
|
|
||||||
|
### #Task
|
||||||
|
- `tId`
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `isCompleted`
|
||||||
|
- `lastChanged`
|
||||||
|
- `uId`
|
||||||
|
- `aId`
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Progress Tracking (Assignments & Subjects)
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
- Implemented progress tracking for both assignments and subjects
|
||||||
|
- Used `Task.isCompleted` as the source of truth
|
||||||
|
- Synced `Assignment.isCompleted` based on task completion
|
||||||
|
|
||||||
|
## Logic implemented
|
||||||
|
- Created `CheckAssignmentCompletion(aId)`
|
||||||
|
- Assignment is marked completed only if all its tasks are completed
|
||||||
|
- Assignment remains incomplete if:
|
||||||
|
- Any task is incomplete
|
||||||
|
- No tasks exist
|
||||||
|
|
||||||
|
## Data handling
|
||||||
|
- Fetched assignments from Supabase
|
||||||
|
- Fetched all related tasks using assignment IDs
|
||||||
|
- Grouped tasks by `aId` into `tasksByAssignment`
|
||||||
|
- Used grouped data to calculate progress efficiently
|
||||||
|
|
||||||
|
## Progress calculation
|
||||||
|
- Assignment progress:
|
||||||
|
- completed tasks / total tasks
|
||||||
|
- Subject progress:
|
||||||
|
- completed tasks across all assignments / total tasks
|
||||||
|
|
||||||
|
## UI work
|
||||||
|
- Added progress bars to:
|
||||||
|
- Assignment cards
|
||||||
|
- Subject views
|
||||||
|
- Used basic inline styling for progress bars
|
||||||
|
- Fixed layout issues caused by incorrect placement inside `flex-row`
|
||||||
|
- Moved progress bar into content column to prevent UI breaking
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- Progress updates dynamically based on task completion
|
||||||
|
- Assignment completion stays in sync with tasks
|
||||||
|
- UI correctly displays both assignment and subject progress
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
## #Overview
|
||||||
|
|
||||||
|
Implemented a full local notification system for assignment deadlines using Expo Notifications, integrated with Supabase-backed assignment data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #CoreFeatures
|
||||||
|
|
||||||
|
### #LocalNotifications
|
||||||
|
|
||||||
|
* Integrated `expo-notifications` for scheduling local device notifications
|
||||||
|
* Configured notification handler for:
|
||||||
|
|
||||||
|
* banner display
|
||||||
|
* sound
|
||||||
|
* badge updates
|
||||||
|
* Notifications trigger even when:
|
||||||
|
|
||||||
|
* app is in background
|
||||||
|
* app is fully closed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DeadlineReminderLogic
|
||||||
|
|
||||||
|
* Implemented reminder scheduling based on assignment deadlines
|
||||||
|
* Default behavior:
|
||||||
|
|
||||||
|
* notify **24 hours before deadline**
|
||||||
|
* Prevented invalid scheduling:
|
||||||
|
|
||||||
|
* skip if reminder time is in the past
|
||||||
|
* validate deadline input before scheduling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #AssignmentIntegration
|
||||||
|
|
||||||
|
* Notifications tied directly to assignment lifecycle:
|
||||||
|
|
||||||
|
#### On Create:
|
||||||
|
|
||||||
|
* Insert assignment into Supabase
|
||||||
|
* Retrieve inserted assignment (`aId`)
|
||||||
|
* Schedule reminder if not completed
|
||||||
|
|
||||||
|
#### On Edit:
|
||||||
|
|
||||||
|
* Cancel existing scheduled notification
|
||||||
|
* Update assignment in Supabase
|
||||||
|
* Schedule new reminder if still active
|
||||||
|
|
||||||
|
#### On Delete:
|
||||||
|
|
||||||
|
* Cancel scheduled notification
|
||||||
|
* Remove stored notification reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #NotificationPersistence
|
||||||
|
|
||||||
|
* Stored notification IDs locally using AsyncStorage
|
||||||
|
* Structure:
|
||||||
|
|
||||||
|
* `assignmentId → notificationId`
|
||||||
|
* Enables:
|
||||||
|
|
||||||
|
* precise cancellation
|
||||||
|
* avoiding duplicate notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #NotificationCancellation
|
||||||
|
|
||||||
|
* Implemented cancellation flow using:
|
||||||
|
|
||||||
|
* `Notifications.cancelScheduledNotificationAsync(notificationId)`
|
||||||
|
* Ensures:
|
||||||
|
|
||||||
|
* no duplicate reminders on edit
|
||||||
|
* no orphan notifications after deletion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #NotificationRouting
|
||||||
|
|
||||||
|
* Implemented navigation on notification tap
|
||||||
|
* Uses:
|
||||||
|
|
||||||
|
* `Notifications.addNotificationResponseReceivedListener`
|
||||||
|
* `Notifications.getLastNotificationResponse()`
|
||||||
|
|
||||||
|
#### Behavior:
|
||||||
|
|
||||||
|
* Works when:
|
||||||
|
|
||||||
|
* app is open
|
||||||
|
* app is in background
|
||||||
|
* app is launched from notification
|
||||||
|
|
||||||
|
#### Routing:
|
||||||
|
|
||||||
|
* Extract `aId` from `notification.content.data`
|
||||||
|
* Navigate using Expo Router:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
router.push({
|
||||||
|
pathname: "/assignment/viewDetailsAssignment",
|
||||||
|
params: { aId }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #AuthIntegration
|
||||||
|
|
||||||
|
* Notification observer only runs when user session exists
|
||||||
|
* Prevents routing into protected screens when unauthenticated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #ArchitectureDecisions
|
||||||
|
|
||||||
|
### #LocalVsBackend
|
||||||
|
|
||||||
|
* Chose **local notifications only**
|
||||||
|
* No backend push notifications used
|
||||||
|
* Rationale:
|
||||||
|
|
||||||
|
* single-user reminders
|
||||||
|
* simpler implementation
|
||||||
|
* no need for push tokens or server logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #DataSeparation
|
||||||
|
|
||||||
|
* Supabase:
|
||||||
|
|
||||||
|
* stores assignment data (source of truth)
|
||||||
|
* Device (AsyncStorage):
|
||||||
|
|
||||||
|
* stores notification IDs (device-specific state)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #RoutingApproach
|
||||||
|
|
||||||
|
* Used existing static route:
|
||||||
|
|
||||||
|
* `/assignment/viewDetailsAssignment`
|
||||||
|
* Passed `aId` via params instead of dynamic route `[aId].tsx`
|
||||||
|
* Keeps current structure intact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #Summary
|
||||||
|
|
||||||
|
A complete local notification system has been implemented with:
|
||||||
|
|
||||||
|
* deadline-based scheduling
|
||||||
|
* lifecycle-aware updates (create/edit/delete)
|
||||||
|
* duplicate prevention
|
||||||
|
* device-level persistence
|
||||||
|
* deep-link style navigation on tap
|
||||||
|
|
||||||
|
This provides a solid, production-ready foundation for assignment reminders within the app.
|
||||||
|
|
||||||
|
|
||||||
|
Interesting sources:
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/async-storage/
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/securestore/
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/notifications/
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# CRUD Testing Summary (React Native + Jest + Supabase)
|
||||||
|
|
||||||
|
## What these tests are about
|
||||||
|
Tests verify **app behavior**, not Supabase itself.
|
||||||
|
|
||||||
|
They check:
|
||||||
|
- User interaction works
|
||||||
|
- Correct database functions are called
|
||||||
|
- Navigation happens after actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRUD Breakdown
|
||||||
|
|
||||||
|
### CREATE
|
||||||
|
- User inputs data
|
||||||
|
- `insert()` is called
|
||||||
|
- App navigates back
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
User → type → press create → insert() → router.back()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### READ
|
||||||
|
- Data is fetched (`select().eq().single()`)
|
||||||
|
- State updates
|
||||||
|
- UI renders correct content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### UPDATE
|
||||||
|
- Existing data is loaded
|
||||||
|
- User edits input
|
||||||
|
- `update().eq()` is called with correct values
|
||||||
|
- Navigation happens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE
|
||||||
|
- User presses delete
|
||||||
|
- `Alert.alert()` is triggered
|
||||||
|
- Confirm button (`onPress`) is manually called in test
|
||||||
|
- `delete().eq()` runs
|
||||||
|
- Navigation happens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why mocking is used
|
||||||
|
- No real database calls
|
||||||
|
- Faster tests
|
||||||
|
- Full control over success/error cases
|
||||||
|
- No side effects (no real data created/deleted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mock rule
|
||||||
|
The mock must match the real call chain:
|
||||||
|
|
||||||
|
Real:
|
||||||
|
from → update → eq → select → single
|
||||||
|
|
||||||
|
Mock:
|
||||||
|
from() → update() → eq() → select() → single()
|
||||||
|
|
||||||
|
If not → errors like:
|
||||||
|
".select is not a function"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
https://oss.callstack.com/react-native-testing-library/
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
AppDev/ikt205_2026_18_study_sprint/README.md
Normal file
23
AppDev/ikt205_2026_18_study_sprint/README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Study Sprint Delivery
|
||||||
|
|
||||||
|
This delivery folder contains the final Study Sprint apk file, report, project vision, source code, demo video, and supporting appendices.
|
||||||
|
|
||||||
|
## Examiner Login Credentials
|
||||||
|
|
||||||
|
We have included `Appendices/examinerCredentials.txt` so the examiner can log in to the app without needing to register and confirm new accounts manually.
|
||||||
|
|
||||||
|
The file contains credentials for two prepared users:
|
||||||
|
|
||||||
|
- **Populated user**: an account with existing subjects, assignments, tasks, completed sprint sessions, and history. This lets the examiner quickly inspect how the app looks and behaves when it has already been used for study planning and focus sessions.
|
||||||
|
- **Fresh user**: a newly created account with only email confirmation completed. This lets the examiner inspect the first-time user experience without going through registration or email confirmation.
|
||||||
|
|
||||||
|
These accounts are included to make assessment faster and more reliable. The examiner can test both the empty starting state and a realistic in-use state of the application.
|
||||||
|
|
||||||
|
## Included Files
|
||||||
|
|
||||||
|
- `studysprint.apk`: Android application package.
|
||||||
|
- `report.pdf`: final project report.
|
||||||
|
- `projectVision.pdf`: project vision document.
|
||||||
|
- `source/`: application source code.
|
||||||
|
- `Appendices/`: appendix material including examiner credentials, work notes and commit history.
|
||||||
|
- `video.mp4`: Demo video of the app and its functions.
|
||||||
BIN
AppDev/ikt205_2026_18_study_sprint/README.pdf
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint/README.pdf
Normal file
Binary file not shown.
BIN
AppDev/ikt205_2026_18_study_sprint/pdf/projectVision.pdf
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint/pdf/projectVision.pdf
Normal file
Binary file not shown.
BIN
AppDev/ikt205_2026_18_study_sprint/pdf/report.pdf
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint/pdf/report.pdf
Normal file
Binary file not shown.
BIN
AppDev/ikt205_2026_18_study_sprint/projectVision.pdf
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint/projectVision.pdf
Normal file
Binary file not shown.
BIN
AppDev/ikt205_2026_18_study_sprint/report.pdf
Normal file
BIN
AppDev/ikt205_2026_18_study_sprint/report.pdf
Normal file
Binary file not shown.
196
AppDev/ikt205_2026_18_study_sprint/source/.gitignore
vendored
Normal file
196
AppDev/ikt205_2026_18_study_sprint/source/.gitignore
vendored
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# ---------------------------
|
||||||
|
# Node / Expo / React Native
|
||||||
|
# ---------------------------
|
||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
.metro-health-check*
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Android / React Native native
|
||||||
|
# Keep /android and /ios ONLY if you commit native code
|
||||||
|
# Remove these two lines if you want generated native folders ignored
|
||||||
|
# ---------------------------
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.aab
|
||||||
|
*.apk
|
||||||
|
output-metadata.json
|
||||||
|
*.hprof
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# IntelliJ / Android Studio / VS Code
|
||||||
|
# ---------------------------
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
misc.xml
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
|
render.experimental.xml
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Secrets / signing / platform keys
|
||||||
|
# ---------------------------
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.mobileprovision
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# iOS / Android generated native folders
|
||||||
|
# Ignore these only for Expo managed/prebuild workflow
|
||||||
|
# Comment them out if you keep native code in repo
|
||||||
|
# ---------------------------
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# .NET / ASP.NET Core Web API
|
||||||
|
# ---------------------------
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/.vs/
|
||||||
|
*.user
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
|
||||||
|
# App settings / local secrets
|
||||||
|
**/appsettings.Development.json
|
||||||
|
**/secrets.json
|
||||||
|
|
||||||
|
# EF Core / local DB artifacts
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Misc
|
||||||
|
# ---------------------------
|
||||||
|
*.orig.*
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Node / Expo / React Native
|
||||||
|
# ---------------------------
|
||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
.metro-health-check*
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Android / React Native native
|
||||||
|
# Keep /android and /ios ONLY if you commit native code
|
||||||
|
# Remove these two lines if you want generated native folders ignored
|
||||||
|
# ---------------------------
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.aab
|
||||||
|
*.apk
|
||||||
|
output-metadata.json
|
||||||
|
*.hprof
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# IntelliJ / Android Studio / VS Code
|
||||||
|
# ---------------------------
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
misc.xml
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
|
render.experimental.xml
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Secrets / signing / platform keys
|
||||||
|
# ---------------------------
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.mobileprovision
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# iOS / Android generated native folders
|
||||||
|
# Ignore these only for Expo managed/prebuild workflow
|
||||||
|
# Comment them out if you keep native code in repo
|
||||||
|
# ---------------------------
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# .NET / ASP.NET Core Web API
|
||||||
|
# ---------------------------
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/.vs/
|
||||||
|
*.user
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
|
||||||
|
# App settings / local secrets
|
||||||
|
**/appsettings.Development.json
|
||||||
|
**/secrets.json
|
||||||
|
|
||||||
|
# EF Core / local DB artifacts
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Misc
|
||||||
|
# ---------------------------
|
||||||
|
*.orig.*
|
||||||
|
app-example
|
||||||
|
newDeps/
|
||||||
105
AppDev/ikt205_2026_18_study_sprint/source/README.md
Normal file
105
AppDev/ikt205_2026_18_study_sprint/source/README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Study Sprint
|
||||||
|
|
||||||
|
Study Sprint is a React Native mobile application built with Expo Router. The app helps students organize study work into subjects, assignments, tasks, and timed focus sessions. Users can create an account, structure their course work, start study sprints from individual tasks, take breaks between sessions, and follow progress from the dashboard.
|
||||||
|
|
||||||
|
The application uses Supabase for authentication and persistent data, while Expo/React Native handles the mobile client, navigation, notifications, and local session state.
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
- Email/password sign up and login with Supabase Auth.
|
||||||
|
- Subject, assignment, and task management.
|
||||||
|
- Task-based sprint timer with focus sessions and breaks.
|
||||||
|
- Dashboard for current progress, active sprint state, and upcoming work.
|
||||||
|
- Local persistence for active session state.
|
||||||
|
- Jest tests for route guarding and CRUD behavior around subjects, assignments, and tasks.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Expo SDK 54
|
||||||
|
- React Native 0.81
|
||||||
|
- Expo Router
|
||||||
|
- TypeScript
|
||||||
|
- Supabase
|
||||||
|
- NativeWind / Tailwind CSS
|
||||||
|
- Jest with `jest-expo`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Install these before running the project locally:
|
||||||
|
|
||||||
|
- Node.js 20.19.4 or newer
|
||||||
|
- npm
|
||||||
|
- Android Studio with an Android emulator, or a physical Android device with USB debugging
|
||||||
|
- Expo CLI through `npx expo`
|
||||||
|
|
||||||
|
## Install Dependencies
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
The project uses `patch-package`, so `npm install` also applies the local patch in `patches/`.
|
||||||
|
|
||||||
|
## Run Locally With Expo
|
||||||
|
|
||||||
|
Start the Expo development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then choose one of the Expo options:
|
||||||
|
|
||||||
|
- Press `a` to open the app in an Android emulator.
|
||||||
|
- Scan the QR code with Expo Go on a physical device.
|
||||||
|
- Press `w` to run the web version for quick UI checks.
|
||||||
|
|
||||||
|
The Android emulator should already be running before pressing `a`.
|
||||||
|
|
||||||
|
## Test and Quality Checks
|
||||||
|
|
||||||
|
Run the Jest test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Expo linting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Run TypeScript checking:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands are the expected local checks before delivery.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/ Expo Router screens and navigation layouts
|
||||||
|
components/ Shared UI components
|
||||||
|
constants/ Shared styling and theme constants
|
||||||
|
hooks/ Shared React hooks
|
||||||
|
lib/ Supabase client, session lifecycle, progress, storage, and utilities
|
||||||
|
__tests__/ Jest test files
|
||||||
|
assets/ App icons, splash assets, and images
|
||||||
|
patches/ patch-package fixes applied after install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Delivery Notes
|
||||||
|
|
||||||
|
For local assessment, the recommended flow is:
|
||||||
|
|
||||||
|
1. Add the required Supabase environment variables.
|
||||||
|
2. Run `npm install`.
|
||||||
|
3. Run `npm test`, `npm run lint`, and `npx tsc --noEmit`.
|
||||||
|
4. Start the app with `npx expo start` and pressing `a` to open the app with your Android Emulator.
|
||||||
|
|
||||||
|
The app is configured as an Expo managed project with generated native folders ignored, so Android/iOS native folders do not need to be committed for normal Expo development.
|
||||||
29
AppDev/ikt205_2026_18_study_sprint/source/Study-Sprint.sln
Normal file
29
AppDev/ikt205_2026_18_study_sprint/source/Study-Sprint.sln
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "study-sprint-api", "backend\study-sprint-api\study-sprint-api.csproj", "{1003D4A4-D46B-F75C-EC68-321C2ED62795}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1003D4A4-D46B-F75C-EC68-321C2ED62795}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{1003D4A4-D46B-F75C-EC68-321C2ED62795} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {2870106D-D84D-4FC9-A7C9-41F972CCDF07}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import UpsertAssignment from "@/app/assignment/upsertAssignment";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
const mockSingle = jest.fn();
|
||||||
|
const mockSelect = jest.fn(() => ({ single: mockSingle, }));
|
||||||
|
const mockInsert = jest.fn(() => ({ select: mockSelect, }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
sId: "subject-123",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/asyncStorage", () => ({
|
||||||
|
GetAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||||
|
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||||
|
RemoveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("expo-notifications", () => ({
|
||||||
|
scheduleNotificationAsync: jest.fn(() => Promise.resolve("notification-123")),
|
||||||
|
SchedulableTriggerInputTypes: {
|
||||||
|
DATE: "date",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
from: jest.fn(() => ({
|
||||||
|
insert: mockInsert,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("creates an assignment and navigates back", async () => {
|
||||||
|
mockSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
aId: "assignment-123",
|
||||||
|
title: "create a simple test",
|
||||||
|
uId: "user-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = render(<UpsertAssignment />);
|
||||||
|
fireEvent.changeText(screen.getByTestId("assignment-title-input"), "create a simple test");
|
||||||
|
fireEvent.press(screen.getByTestId("upsert-assignment-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "create a simple test",
|
||||||
|
uId: "user-123",
|
||||||
|
sId: "subject-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockSingle).toHaveBeenCalled();
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import ViewDetailsAssignment from "@/app/assignment/viewDetailsAssignment";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
const mockAssignmentSingle = jest.fn();
|
||||||
|
const mockAssignmentSelectEq = jest.fn(() => ({ single: mockAssignmentSingle, }));
|
||||||
|
const mockAssignmentSelect = jest.fn(() => ({ eq: mockAssignmentSelectEq, }));
|
||||||
|
const mockAssignmentDeleteEq = jest.fn();
|
||||||
|
const mockAssignmentDelete = jest.fn(() => ({ eq: mockAssignmentDeleteEq, }));
|
||||||
|
|
||||||
|
const mockTasksSelectEq = jest.fn();
|
||||||
|
const mockTasksSelect = jest.fn(() => ({ eq: mockTasksSelectEq }));
|
||||||
|
|
||||||
|
const mockSubjectSingle = jest.fn();
|
||||||
|
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
|
||||||
|
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
aId: "assignment-123",
|
||||||
|
}),
|
||||||
|
useFocusEffect: (callback: () => void) => {
|
||||||
|
const React = require("react");
|
||||||
|
React.useEffect(callback, [callback]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
getSession: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
user: { id: "user-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onAuthStateChange: jest.fn(() => ({
|
||||||
|
data: {
|
||||||
|
subscription: {
|
||||||
|
unsubscribe: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
from: jest.fn((table: string) => {
|
||||||
|
if (table === "assignments") {
|
||||||
|
return {
|
||||||
|
select: mockAssignmentSelect,
|
||||||
|
delete: mockAssignmentDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "tasks") {
|
||||||
|
return {
|
||||||
|
select: mockTasksSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "subjects") {
|
||||||
|
return {
|
||||||
|
select: mockSubjectSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alertSpy = jest.spyOn(Alert, "alert");
|
||||||
|
|
||||||
|
test("deletes an assignment and navigates back", async () => {
|
||||||
|
mockAssignmentSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
aId: "assignment-123",
|
||||||
|
title: "create a simple test",
|
||||||
|
uId: "user-123",
|
||||||
|
sId: "subject-123"
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockTasksSelectEq.mockResolvedValue({ data: [], error: null, })
|
||||||
|
mockSubjectSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
sId: "subject-123",
|
||||||
|
title: "ikt205g26v",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockAssignmentDeleteEq.mockResolvedValue({ error: null, });
|
||||||
|
|
||||||
|
const screen = render(<ViewDetailsAssignment />);
|
||||||
|
|
||||||
|
await screen.findByText("create a simple test");
|
||||||
|
await screen.findByText("ikt205g26v");
|
||||||
|
|
||||||
|
fireEvent.press(await screen.findByTestId("delete-assignment-button"));
|
||||||
|
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith(
|
||||||
|
"Delete Assignment",
|
||||||
|
"Are you sure you want to delete this assignment?",
|
||||||
|
expect.any(Array),
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertButtons = alertSpy.mock.calls[0]?.[2];
|
||||||
|
expect(alertButtons).toBeDefined();
|
||||||
|
const confirmDeleteButton = alertButtons?.[1];
|
||||||
|
expect(confirmDeleteButton?.onPress).toBeDefined();
|
||||||
|
|
||||||
|
if (!confirmDeleteButton?.onPress) {
|
||||||
|
throw new Error("Delete confirmation button missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirmDeleteButton.onPress();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||||
|
expect(mockAssignmentDelete).toHaveBeenCalled();
|
||||||
|
expect(mockAssignmentDeleteEq).toHaveBeenCalledWith("aId", "assignment-123");
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import UpsertAssignment from "@/app/assignment/upsertAssignment";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
const mockUpdateSingle = jest.fn();
|
||||||
|
const mockUpdateSelect = jest.fn(() => ({ single: mockUpdateSingle, }));
|
||||||
|
const mockUpdateEq = jest.fn(() => ({ select: mockUpdateSelect, }));
|
||||||
|
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
|
||||||
|
const mockSingle = jest.fn();
|
||||||
|
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
|
||||||
|
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
aId: "assignment-123",
|
||||||
|
sId: "subject-123",
|
||||||
|
}),
|
||||||
|
useFocusEffect: (callback: () => void) => callback(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/asyncStorage", () => ({
|
||||||
|
GetAssignmentNotificationId: jest.fn(() => Promise.resolve(null)),
|
||||||
|
SaveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||||
|
RemoveAssignmentNotificationId: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("expo-notifications", () => ({
|
||||||
|
scheduleNotificationAsync: jest.fn(() => Promise.resolve("notification-123")),
|
||||||
|
SchedulableTriggerInputTypes: {
|
||||||
|
DATE: "date",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
from: jest.fn(() => ({
|
||||||
|
select: mockSelect,
|
||||||
|
update: mockUpdate,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("updates an assignment and navigates back", async () => {
|
||||||
|
mockSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
aId: "assignment-123",
|
||||||
|
title: "create a simple test",
|
||||||
|
uId: "user-123",
|
||||||
|
sId: "subject-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockUpdateSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
aId: "assignment-123",
|
||||||
|
title: "create a harder test",
|
||||||
|
uId: "user-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = render(<UpsertAssignment />);
|
||||||
|
fireEvent.changeText(await screen.findByTestId("assignment-title-input"), "create a harder test");
|
||||||
|
fireEvent.press(screen.getByTestId("upsert-assignment-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("assignments");
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "create a harder test",
|
||||||
|
uId: "user-123",
|
||||||
|
sId: "subject-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockUpdateEq).toHaveBeenCalledWith("aId", "assignment-123");
|
||||||
|
expect(mockUpdateSingle).toHaveBeenCalled();
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import TabLayout from "@/app/(tabs)/_layout";
|
||||||
|
import { getSetupStatus } from "@/lib/setupStatus";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { render, waitFor } from "@testing-library/react-native";
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => {
|
||||||
|
const React = require("react");
|
||||||
|
const { Text, View } = require("react-native");
|
||||||
|
|
||||||
|
const MockTabs = ({ children }: { children?: React.ReactNode }) => (
|
||||||
|
<View>
|
||||||
|
<Text>tabs</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
MockTabs.Screen = () => null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Redirect: ({ href }: { href: string }) => <Text>redirect:{href}</Text>,
|
||||||
|
Tabs: MockTabs,
|
||||||
|
router: {
|
||||||
|
push: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("expo-notifications", () => ({
|
||||||
|
getLastNotificationResponse: jest.fn(() => null),
|
||||||
|
addNotificationResponseReceivedListener: jest.fn(() => ({
|
||||||
|
remove: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/setupStatus", () => ({
|
||||||
|
getSetupStatus: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getSession: jest.fn(),
|
||||||
|
onAuthStateChange: jest.fn(() => ({
|
||||||
|
data: {
|
||||||
|
subscription: {
|
||||||
|
unsubscribe: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(getSetupStatus as jest.Mock).mockResolvedValue({
|
||||||
|
subjectId: "subject-123",
|
||||||
|
assignmentId: "assignment-123",
|
||||||
|
taskId: "task-123",
|
||||||
|
completedFocusSessions: 1,
|
||||||
|
currentStep: "sprint",
|
||||||
|
isSetupComplete: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects to login if there is no session", async () => {
|
||||||
|
(supabase.auth.getSession as jest.Mock).mockResolvedValue({
|
||||||
|
data: { session: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = render(<TabLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("redirect:/login")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders tabs when session exists", async () => {
|
||||||
|
(supabase.auth.getSession as jest.Mock).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
user: { id: "user-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = render(<TabLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("tabs")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import UpsertSubject from "@/app/subject/upsertSubject";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
const mockSingle = jest.fn();
|
||||||
|
const mockSelect = jest.fn(() => ({ single: mockSingle }));
|
||||||
|
const mockInsert = jest.fn(() => ({ select: mockSelect }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
from: jest.fn(() => ({
|
||||||
|
insert: mockInsert,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("creates a subject and navigates back", async () => {
|
||||||
|
mockSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
sId: "subject-123",
|
||||||
|
title: "ikt205g26v",
|
||||||
|
uId: "user-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = render(<UpsertSubject />);
|
||||||
|
fireEvent.changeText(screen.getByTestId("subject-title-input"), "ikt205g26v");
|
||||||
|
fireEvent.press(screen.getByTestId("upsert-subject-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("subjects");
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "ikt205g26v",
|
||||||
|
uId: "user-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockSingle).toHaveBeenCalled();
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import ViewDetailsSubject from "@/app/subject/viewDetailsSubject";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
const mockSubjectSingle = jest.fn();
|
||||||
|
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
|
||||||
|
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
|
||||||
|
const mockSubjectDeleteEq = jest.fn();
|
||||||
|
const mockSubjectDelete = jest.fn(() => ({ eq: mockSubjectDeleteEq }));
|
||||||
|
|
||||||
|
const mockAssignmentsOrder = jest.fn();
|
||||||
|
const mockAssignmentsEq = jest.fn(() => ({ order: mockAssignmentsOrder }));
|
||||||
|
const mockAssignmentsSelect = jest.fn(() => ({ eq: mockAssignmentsEq }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
sId: "subject-123",
|
||||||
|
}),
|
||||||
|
useFocusEffect: (callback: () => void) => {
|
||||||
|
const React = require("react");
|
||||||
|
React.useEffect(callback, [callback]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
getSession: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
user: { id: "user-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onAuthStateChange: jest.fn(() => ({
|
||||||
|
data: {
|
||||||
|
subscription: {
|
||||||
|
unsubscribe: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
from: jest.fn((table) => {
|
||||||
|
if (table === "subjects") {
|
||||||
|
return {
|
||||||
|
select: mockSubjectSelect,
|
||||||
|
delete: mockSubjectDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "assignments") {
|
||||||
|
return {
|
||||||
|
select: mockAssignmentsSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alertSpy = jest.spyOn(Alert, "alert");
|
||||||
|
|
||||||
|
test("deletes a subject and navigates back", async () => {
|
||||||
|
mockSubjectSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
sId: "subject-123",
|
||||||
|
title: "ikt205g26v",
|
||||||
|
uId: "user-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockAssignmentsOrder.mockResolvedValue({ data: [], error: null, })
|
||||||
|
mockSubjectDeleteEq.mockResolvedValue({ error: null, });
|
||||||
|
|
||||||
|
const screen = render(<ViewDetailsSubject />);
|
||||||
|
|
||||||
|
await screen.findByText("ikt205g26v");
|
||||||
|
|
||||||
|
fireEvent.press(await screen.findByTestId("delete-subject-button"));
|
||||||
|
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith(
|
||||||
|
"Delete Subject",
|
||||||
|
"Are you sure you want to delete this subject?",
|
||||||
|
expect.any(Array),
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertButtons = alertSpy.mock.calls[0]?.[2];
|
||||||
|
expect(alertButtons).toBeDefined();
|
||||||
|
const confirmDeleteButton = alertButtons?.[1];
|
||||||
|
expect(confirmDeleteButton?.onPress).toBeDefined();
|
||||||
|
|
||||||
|
if (!confirmDeleteButton?.onPress) {
|
||||||
|
throw new Error("Delete confirmation button missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirmDeleteButton.onPress();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("subjects");
|
||||||
|
expect(mockSubjectDelete).toHaveBeenCalled();
|
||||||
|
expect(mockSubjectDeleteEq).toHaveBeenCalledWith("sId", "subject-123");
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import UpsertSubject from "@/app/subject/upsertSubject";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
const mockUpdateEq = jest.fn();
|
||||||
|
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
|
||||||
|
const mockSingle = jest.fn();
|
||||||
|
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
|
||||||
|
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
sId: "subject-123",
|
||||||
|
}),
|
||||||
|
useFocusEffect: (callback: () => void) => callback(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
from: jest.fn(() => ({
|
||||||
|
select: mockSelect,
|
||||||
|
update: mockUpdate,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("updates a subject and navigates back", async () => {
|
||||||
|
mockSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
sId: "subject-123",
|
||||||
|
title: "ikt205g26v",
|
||||||
|
uId: "user-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockUpdateEq.mockResolvedValue({ error: null, });
|
||||||
|
|
||||||
|
const screen = render(<UpsertSubject />);
|
||||||
|
fireEvent.changeText(await screen.findByTestId("subject-title-input"), "ikt206g26v");
|
||||||
|
fireEvent.press(screen.getByTestId("upsert-subject-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("subjects");
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "ikt206g26v",
|
||||||
|
uId: "user-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockUpdateEq).toHaveBeenCalledWith("sId", "subject-123");
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import UpsertTask from "@/app/task/upsertTask";
|
||||||
|
import { CheckAssignmentCompletion } from "@/lib/progress";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
const mockSingle = jest.fn();
|
||||||
|
const mockSelect = jest.fn(() => ({ single: mockSingle }));
|
||||||
|
const mockInsert = jest.fn(() => ({ select: mockSelect }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
aId: "assignment-123",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/progress", () => ({
|
||||||
|
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
from: jest.fn(() => ({
|
||||||
|
insert: mockInsert,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("creates a task and navigates back", async () => {
|
||||||
|
mockSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
tId: "task-123",
|
||||||
|
title: "Read chapter 4",
|
||||||
|
uId: "user-123",
|
||||||
|
aId: "assignment-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = render(<UpsertTask />);
|
||||||
|
fireEvent.changeText(screen.getByTestId("task-title-input"), "Read chapter 4");
|
||||||
|
fireEvent.press(screen.getByTestId("upsert-task-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("tasks");
|
||||||
|
expect(mockInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Read chapter 4",
|
||||||
|
uId: "user-123",
|
||||||
|
aId: "assignment-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockSingle).toHaveBeenCalled();
|
||||||
|
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import ViewDetailsTask from "@/app/task/viewDetailsTask";
|
||||||
|
import { CheckAssignmentCompletion } from "@/lib/progress";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
const mockTaskSingle = jest.fn();
|
||||||
|
const mockTaskSelectEq = jest.fn(() => ({ single: mockTaskSingle }));
|
||||||
|
const mockTaskSelect = jest.fn(() => ({ eq: mockTaskSelectEq }));
|
||||||
|
const mockTaskDeleteEq = jest.fn();
|
||||||
|
const mockTaskDelete = jest.fn(() => ({ eq: mockTaskDeleteEq }));
|
||||||
|
|
||||||
|
const mockAssignmentSingle = jest.fn();
|
||||||
|
const mockAssignmentSelectEq = jest.fn(() => ({ single: mockAssignmentSingle }));
|
||||||
|
const mockAssignmentSelect = jest.fn(() => ({ eq: mockAssignmentSelectEq }));
|
||||||
|
|
||||||
|
const mockSubjectSingle = jest.fn();
|
||||||
|
const mockSubjectSelectEq = jest.fn(() => ({ single: mockSubjectSingle }));
|
||||||
|
const mockSubjectSelect = jest.fn(() => ({ eq: mockSubjectSelectEq }));
|
||||||
|
|
||||||
|
const mockSprintSessionsEqCompleted = jest.fn();
|
||||||
|
const mockSprintSessionsEqSessionType = jest.fn(() => ({ eq: mockSprintSessionsEqCompleted }));
|
||||||
|
const mockSprintSessionsEqUser = jest.fn(() => ({ eq: mockSprintSessionsEqSessionType }));
|
||||||
|
const mockSprintSessionsEqTask = jest.fn(() => ({ eq: mockSprintSessionsEqUser }));
|
||||||
|
const mockSprintSessionsSelect = jest.fn(() => ({ eq: mockSprintSessionsEqTask }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
tId: "task-123",
|
||||||
|
}),
|
||||||
|
useFocusEffect: (callback: () => void) => {
|
||||||
|
const React = require("react");
|
||||||
|
React.useEffect(callback, [callback]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/progress", () => ({
|
||||||
|
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
getSession: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
user: { id: "user-123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onAuthStateChange: jest.fn(() => ({
|
||||||
|
data: {
|
||||||
|
subscription: {
|
||||||
|
unsubscribe: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
from: jest.fn((table: string) => {
|
||||||
|
if (table === "tasks") {
|
||||||
|
return {
|
||||||
|
select: mockTaskSelect,
|
||||||
|
delete: mockTaskDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "assignments") {
|
||||||
|
return {
|
||||||
|
select: mockAssignmentSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "subjects") {
|
||||||
|
return {
|
||||||
|
select: mockSubjectSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table === "sprint_sessions") {
|
||||||
|
return {
|
||||||
|
select: mockSprintSessionsSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alertSpy = jest.spyOn(Alert, "alert");
|
||||||
|
|
||||||
|
test("deletes a task and navigates back", async () => {
|
||||||
|
mockTaskSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
tId: "task-123",
|
||||||
|
title: "Read chapter 4",
|
||||||
|
uId: "user-123",
|
||||||
|
aId: "assignment-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockAssignmentSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
aId: "assignment-123",
|
||||||
|
title: "create a simple test",
|
||||||
|
uId: "user-123",
|
||||||
|
sId: "subject-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockSubjectSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
sId: "subject-123",
|
||||||
|
title: "ikt205g26v",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockSprintSessionsEqCompleted.mockResolvedValue({ count: 0, error: null });
|
||||||
|
mockTaskDeleteEq.mockResolvedValue({ error: null, });
|
||||||
|
|
||||||
|
const screen = render(<ViewDetailsTask />);
|
||||||
|
|
||||||
|
await screen.findByText("Read chapter 4");
|
||||||
|
await screen.findByText("ikt205g26v");
|
||||||
|
|
||||||
|
fireEvent.press(await screen.findByText("Delete"));
|
||||||
|
|
||||||
|
expect(alertSpy).toHaveBeenCalledWith(
|
||||||
|
"Delete Task",
|
||||||
|
"Are you sure you want to delete this task?",
|
||||||
|
expect.any(Array),
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertButtons = alertSpy.mock.calls[0]?.[2];
|
||||||
|
expect(alertButtons).toBeDefined();
|
||||||
|
const confirmDeleteButton = alertButtons?.[1];
|
||||||
|
expect(confirmDeleteButton?.onPress).toBeDefined();
|
||||||
|
|
||||||
|
if (!confirmDeleteButton?.onPress) {
|
||||||
|
throw new Error("Delete confirmation button missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirmDeleteButton.onPress();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("tasks");
|
||||||
|
expect(mockTaskDelete).toHaveBeenCalled();
|
||||||
|
expect(mockTaskDeleteEq).toHaveBeenCalledWith("tId", "task-123");
|
||||||
|
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import UpsertTask from "@/app/task/upsertTask";
|
||||||
|
import { CheckAssignmentCompletion } from "@/lib/progress";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
const mockUpdateEq = jest.fn();
|
||||||
|
const mockUpdate = jest.fn(() => ({ eq: mockUpdateEq, }));
|
||||||
|
const mockSingle = jest.fn();
|
||||||
|
const mockSelectEq = jest.fn(() => ({ single: mockSingle, }));
|
||||||
|
const mockSelect = jest.fn(() => ({ eq: mockSelectEq, }));
|
||||||
|
|
||||||
|
jest.mock("expo-router", () => ({
|
||||||
|
router: {
|
||||||
|
back: jest.fn(),
|
||||||
|
replace: jest.fn(),
|
||||||
|
},
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({
|
||||||
|
tId: "task-123",
|
||||||
|
}),
|
||||||
|
useFocusEffect: (callback: () => void) => callback(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/progress", () => ({
|
||||||
|
CheckAssignmentCompletion: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/supabase", () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getUser: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { user: { id: "user-123" } },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
from: jest.fn(() => ({
|
||||||
|
select: mockSelect,
|
||||||
|
update: mockUpdate,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("updates a task and navigates back", async () => {
|
||||||
|
mockSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
tId: "task-123",
|
||||||
|
title: "Read chapter 4",
|
||||||
|
uId: "user-123",
|
||||||
|
aId: "assignment-123",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
mockUpdateEq.mockResolvedValue({ error: null, });
|
||||||
|
|
||||||
|
const screen = render(<UpsertTask />);
|
||||||
|
fireEvent.changeText(await screen.findByTestId("task-title-input"), "Read chapter 5");
|
||||||
|
fireEvent.press(screen.getByTestId("upsert-task-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith("tasks");
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Read chapter 5",
|
||||||
|
uId: "user-123",
|
||||||
|
aId: "assignment-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockUpdateEq).toHaveBeenCalledWith("tId", "task-123");
|
||||||
|
expect(CheckAssignmentCompletion).toHaveBeenCalledWith("assignment-123");
|
||||||
|
expect(router.back).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
AppDev/ikt205_2026_18_study_sprint/source/app.json
Normal file
57
AppDev/ikt205_2026_18_study_sprint/source/app.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Study Sprint",
|
||||||
|
"slug": "Study-Sprint",
|
||||||
|
"owner": "ikt205g26v-g18",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "studysprint",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.softsand.studysprint"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"dark": {
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "2b2ec99b-a2ea-4991-8694-93f9e3d042a3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
AppDev/ikt205_2026_18_study_sprint/source/app/(tabs)/_layout.tsx
Normal file
118
AppDev/ikt205_2026_18_study_sprint/source/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { getSetupStatus } from "@/lib/setupStatus";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { Session } from "@supabase/supabase-js";
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { Redirect, router, Tabs } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function UseNotificationObserver() {
|
||||||
|
useEffect(() => {
|
||||||
|
function redirect(notification: Notifications.Notification) {
|
||||||
|
const aId = notification.request.content.data?.aId;
|
||||||
|
|
||||||
|
if (typeof aId === 'string') {
|
||||||
|
router.push({pathname: "/assignment/viewDetailsAssignment", params: { aId }});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = Notifications.getLastNotificationResponse();
|
||||||
|
if (response?.notification) {
|
||||||
|
redirect(response.notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = Notifications.addNotificationResponseReceivedListener(response => {
|
||||||
|
redirect(response.notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const [session, SetSession] = useState<Session | null>(null)
|
||||||
|
const [loading, SetLoading] = useState(true);
|
||||||
|
const [setupChecked, setSetupChecked] = useState(false);
|
||||||
|
const [needsSetup, setNeedsSetup] = useState(false);
|
||||||
|
|
||||||
|
UseNotificationObserver();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSession = async () => {
|
||||||
|
const { data } = await supabase.auth.getSession();
|
||||||
|
SetSession(data.session ?? null);
|
||||||
|
SetLoading(false);
|
||||||
|
}
|
||||||
|
loadSession();
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
SetSession(newSession);
|
||||||
|
SetLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSetupStatus = async () => {
|
||||||
|
if (!session?.user.id) {
|
||||||
|
setNeedsSetup(false);
|
||||||
|
setSetupChecked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setupStatus = await getSetupStatus(session.user.id);
|
||||||
|
setNeedsSetup(!setupStatus.isSetupComplete);
|
||||||
|
} catch {
|
||||||
|
setNeedsSetup(true);
|
||||||
|
} finally {
|
||||||
|
setSetupChecked(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setSetupChecked(false);
|
||||||
|
void checkSetupStatus();
|
||||||
|
}, [session?.user.id]);
|
||||||
|
|
||||||
|
if (loading || !setupChecked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return <Redirect href="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Redirect href="/setup" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: true,
|
||||||
|
}}>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Dashboard',
|
||||||
|
tabBarLabel: 'Dashboard',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialIcons name="dashboard" color={color} size={size} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="subjects"
|
||||||
|
options={{
|
||||||
|
title: "Subjects",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialIcons name="menu-book" color={color} size={size} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
1028
AppDev/ikt205_2026_18_study_sprint/source/app/(tabs)/index.tsx
Normal file
1028
AppDev/ikt205_2026_18_study_sprint/source/app/(tabs)/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
|||||||
|
import { getSetupStatus } from '@/lib/setupStatus';
|
||||||
|
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { Subject } from '@/lib/types';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
|
import { Redirect, router, Stack, useFocusEffect } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Alert, Modal, Pressable, ScrollView, Text, View, ActivityIndicator } from 'react-native';
|
||||||
|
|
||||||
|
const FLOW_STEPS = [
|
||||||
|
{
|
||||||
|
label: '1',
|
||||||
|
title: 'Subject',
|
||||||
|
description: 'Start with the broad area you are studying, like one course or one exam you are preparing for.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2',
|
||||||
|
title: 'Assignment',
|
||||||
|
description: 'Inside that, add the bigger piece of work you want to move forward, like a project, a problem set, or revision block.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '3',
|
||||||
|
title: 'Task',
|
||||||
|
description: 'Then break it down into one task that feels concrete enough to begin without overthinking it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '4',
|
||||||
|
title: 'Sprint',
|
||||||
|
description: 'That task is what you bring into a focus session. After a sprint, you take a short pause, then come back to the same kind of focused work. After a few rounds, the app gives you a longer pause so the rhythm still feels sustainable.',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function Subjects() {
|
||||||
|
const [subjects, SetSubjects] = useState<Subject[]>([]);
|
||||||
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
|
const [isFlowInfoVisible, setIsFlowInfoVisible] = useState(false);
|
||||||
|
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||||
|
const [isLoading, SetIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const activeSubjects = subjects.filter((subject) => subject.isActive);
|
||||||
|
const inactiveSubjects = subjects.filter((subject) => !subject.isActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
SetSession(data.session ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange(
|
||||||
|
(_event, newSession) => {
|
||||||
|
SetSession(newSession);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => sub.subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSetupGate = async () => {
|
||||||
|
if (!session?.user.id) {
|
||||||
|
setNeedsSetup(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setupStatus = await getSetupStatus(session.user.id);
|
||||||
|
setNeedsSetup(!setupStatus.isSetupComplete);
|
||||||
|
} catch {
|
||||||
|
setNeedsSetup(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setNeedsSetup(null);
|
||||||
|
void loadSetupGate();
|
||||||
|
}, [session?.user.id]);
|
||||||
|
|
||||||
|
const GetSubjects = useCallback(async () => {
|
||||||
|
if (!session?.user.id) {
|
||||||
|
SetIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('subjects')
|
||||||
|
.select('*')
|
||||||
|
.eq('uId', session.user.id)
|
||||||
|
.order('lastChanged', { ascending: false });
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Subjects could not be fetched, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSubjects((data as Subject[]) ?? []);
|
||||||
|
}, [session?.user.id]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (session) {
|
||||||
|
void GetSubjects();
|
||||||
|
}
|
||||||
|
}, [GetSubjects, session])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (session && needsSetup === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Redirect href="/setup" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenderSubjectCard = (subject: Subject) => {
|
||||||
|
const colorKey: SubjectColor = subject.color ?? 'slate';
|
||||||
|
const colorSet = SUBJECT_COLORS[colorKey];
|
||||||
|
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={subject.sId}
|
||||||
|
className="mb-4 rounded-3xl bg-app-surface p-4"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/subject/viewDetailsSubject',
|
||||||
|
params: { sId: subject.sId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<View
|
||||||
|
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: colorSet.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{firstLetter}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold text-text-main"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{subject.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
className="mt-1 text-sm leading-5 text-text-secondary"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{subject.description || 'No description added.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="ml-3">
|
||||||
|
<View
|
||||||
|
className="rounded-full px-3 py-1"
|
||||||
|
style={{ backgroundColor: colorSet.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-semibold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{subject.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Subjects',
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
headerLeft: () => (
|
||||||
|
<View className="ml-3">
|
||||||
|
<Pressable
|
||||||
|
className="h-10.5 w-11 items-center justify-center rounded-full"
|
||||||
|
onPress={() => setIsFlowInfoVisible(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="help" size={36} color="#52606D" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
headerRight: () => (
|
||||||
|
<View className="mr-3">
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full bg-app-subtle px-4 py-2"
|
||||||
|
onPress={async () => await supabase.auth.signOut()}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Logout
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
animationType="fade"
|
||||||
|
transparent
|
||||||
|
visible={isFlowInfoVisible}
|
||||||
|
onRequestClose={() => setIsFlowInfoVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center bg-[rgba(15,23,42,0.42)] px-5">
|
||||||
|
<Pressable
|
||||||
|
className="absolute inset-0"
|
||||||
|
onPress={() => setIsFlowInfoVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="max-h-[80%] gap-4 rounded-[28px] bg-[#FCFDFE] p-5 shadow-lg">
|
||||||
|
<View className="flex-row items-start justify-between gap-3">
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
|
||||||
|
How work is organized
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-[28px] font-extrabold text-[#1F2933]">
|
||||||
|
Study flow
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="h-9 w-9 items-center justify-center rounded-full bg-[#EFF3F8]"
|
||||||
|
onPress={() => setIsFlowInfoVisible(false)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="close" size={18} color="#52606D" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-[15px] leading-[22px] text-[#52606D]">
|
||||||
|
The idea is to make getting started feel lighter. You decide what you are studying, narrow it down to one clear task, and then let the app carry you through a simple rhythm of focus and recovery.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="max-h-80"
|
||||||
|
contentContainerStyle={{ gap: 4 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{FLOW_STEPS.map((step, index) => (
|
||||||
|
<View key={step.title} className="flex-row gap-[14px]">
|
||||||
|
<View className="items-center">
|
||||||
|
<View className="h-8 w-8 items-center justify-center rounded-full bg-[#323F4E]">
|
||||||
|
<Text className="text-[13px] font-extrabold text-white">
|
||||||
|
{step.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{index < FLOW_STEPS.length - 1 ? (
|
||||||
|
<View className="my-[6px] min-h-7 w-[2px] flex-1 bg-[#D5D9DF]" />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 pb-[18px]">
|
||||||
|
<Text className="text-lg font-bold text-[#1F2933]">
|
||||||
|
{step.title}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm leading-[21px] text-[#52606D]">
|
||||||
|
{step.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View className="rounded-[18px] bg-[#F1F5F9] px-4 py-[14px]">
|
||||||
|
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-[#7B8794]">
|
||||||
|
Quick map
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-[6px] text-base font-bold text-[#1F2933]">
|
||||||
|
{'Subject -> Assignment -> Task -> Sprint'}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-sm leading-[20px] text-[#52606D]">
|
||||||
|
In practice, that usually becomes focus session, short pause, focus session again, and eventually a longer pause when you have done a few solid rounds.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="min-h-12 items-center justify-center rounded-2xl bg-[#323F4E] px-4"
|
||||||
|
onPress={() => setIsFlowInfoVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-[15px] font-bold text-white">Close Guide</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 32,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="items-center justify-center rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<ActivityIndicator size="large" color="#2563eb" />
|
||||||
|
<Text className="mt-4 text-center text-base font-semibold text-text-secondary">
|
||||||
|
Loading subjects...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : subjects.length === 0 ? (
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-center text-xl font-bold text-text-main">
|
||||||
|
No subjects yet
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-center text-sm leading-5 text-text-secondary">
|
||||||
|
Start with one subject so the rest of your study path has a clear
|
||||||
|
place to live.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() => router.push('/setup')}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Start Guided Setup
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||||
|
<Text className="text-lg font-bold text-text-main">
|
||||||
|
Active Subjects
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-muted">
|
||||||
|
{activeSubjects.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeSubjects.length === 0 ? (
|
||||||
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
|
No active subjects
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
|
Subjects with ongoing work will show up here.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
activeSubjects.map(RenderSubjectCard)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||||
|
<Text className="text-lg font-bold text-text-main">
|
||||||
|
Inactive Subjects
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-muted">
|
||||||
|
{inactiveSubjects.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{inactiveSubjects.length === 0 ? (
|
||||||
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
|
No inactive subjects
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
|
Completed or paused subjects will show up here.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
inactiveSubjects.map(RenderSubjectCard)
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subjects.length > 0 ? (
|
||||||
|
<Pressable
|
||||||
|
className="mt-2 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() => router.push('/subject/upsertSubject')}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Create Subject
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
AppDev/ikt205_2026_18_study_sprint/source/app/_layout.tsx
Normal file
16
AppDev/ikt205_2026_18_study_sprint/source/app/_layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import "../global.css";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="setup" options={{ headerShown: true }} />
|
||||||
|
<Stack.Screen name="subject" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="assignment" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="task" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="createUser" options={{ headerShown: true, title: "" }} />
|
||||||
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AssignmentLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="upsertAssignment" options={{ title: 'Create/Edit Assignment' }} />
|
||||||
|
<Stack.Screen name="viewDetailsAssignment" options={{ title: "Assignment Details" }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { defaultStyles } from '@/constants/defaultStyles';
|
||||||
|
import * as AsyncStorage from '@/lib/asyncStorage';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { router, Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function UpsertAssignment() {
|
||||||
|
const { aId, sId: routeSId, flow } = useLocalSearchParams<{
|
||||||
|
aId?: string;
|
||||||
|
sId?: string;
|
||||||
|
flow?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isEditMode = Boolean(aId);
|
||||||
|
const isSetupFlow = flow === 'setup';
|
||||||
|
|
||||||
|
const [title, SetTitle] = useState('');
|
||||||
|
const [description, SetDescription] = useState('');
|
||||||
|
const [deadline, SetDeadline] = useState('');
|
||||||
|
const [isCompleted, SetIsCompleted] = useState(false);
|
||||||
|
const [subjectId, SetSubjectId] = useState<string | null>(routeSId ?? null);
|
||||||
|
|
||||||
|
const [isLoading, SetIsLoading] = useState(isEditMode);
|
||||||
|
const [isSaving, SetIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditMode || !aId) {
|
||||||
|
SetIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAssignment = async () => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.select('*')
|
||||||
|
.eq('aId', aId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
Alert.alert('Assignment could not be loaded, please try again');
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetTitle(data.title ?? '');
|
||||||
|
SetDescription(data.description ?? '');
|
||||||
|
SetDeadline(data.deadline ?? '');
|
||||||
|
SetIsCompleted(data.isCompleted ?? false);
|
||||||
|
SetSubjectId(data.sId ?? routeSId ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAssignment();
|
||||||
|
}, [aId, isEditMode, routeSId]);
|
||||||
|
|
||||||
|
const ScheduleDeadlineReminder = async (
|
||||||
|
assignmentId: string,
|
||||||
|
assignmentTitle: string,
|
||||||
|
assignmentDeadline: string
|
||||||
|
) => {
|
||||||
|
const dl = new Date(assignmentDeadline);
|
||||||
|
|
||||||
|
if (Number.isNaN(dl.getTime())) return null;
|
||||||
|
|
||||||
|
const deadlineReminder = new Date(dl.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (deadlineReminder <= new Date()) return null;
|
||||||
|
|
||||||
|
const nId = await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: 'Assignment deadline coming up',
|
||||||
|
body: `${assignmentTitle} is due in 24 hours.`,
|
||||||
|
data: { aId: assignmentId },
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||||
|
date: deadlineReminder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return nId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDeadlineReminder = async (
|
||||||
|
assignmentId: string,
|
||||||
|
assignmentTitle: string,
|
||||||
|
assignmentDeadline: string,
|
||||||
|
completed: boolean
|
||||||
|
) => {
|
||||||
|
const existingNotificationId =
|
||||||
|
await AsyncStorage.GetAssignmentNotificationId(assignmentId);
|
||||||
|
|
||||||
|
if (existingNotificationId) {
|
||||||
|
try {
|
||||||
|
await Notifications.cancelScheduledNotificationAsync(
|
||||||
|
existingNotificationId
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
await AsyncStorage.RemoveAssignmentNotificationId(assignmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed) return;
|
||||||
|
|
||||||
|
const nId = await ScheduleDeadlineReminder(
|
||||||
|
assignmentId,
|
||||||
|
assignmentTitle,
|
||||||
|
assignmentDeadline
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nId) {
|
||||||
|
await AsyncStorage.SaveAssignmentNotificationId(assignmentId, nId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (title.trim() === '') {
|
||||||
|
Alert.alert('Title is required!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !userData.user) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subjectId) {
|
||||||
|
Alert.alert('Missing subject', 'This assignment is not linked to a subject.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsSaving(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
deadline: deadline.trim(),
|
||||||
|
isCompleted,
|
||||||
|
lastChanged: new Date().toISOString(),
|
||||||
|
uId: userData.user.id,
|
||||||
|
sId: subjectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isEditMode && aId
|
||||||
|
? await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.update(payload)
|
||||||
|
.eq('aId', aId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
: await supabase.from('assignments').insert(payload).select().single();
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
SetIsSaving(false);
|
||||||
|
Alert.alert(
|
||||||
|
isEditMode
|
||||||
|
? 'Assignment could not be updated, please try again'
|
||||||
|
: 'Assignment could not be created, please try again'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedAssignment = result.data;
|
||||||
|
|
||||||
|
await updateDeadlineReminder(
|
||||||
|
savedAssignment.aId,
|
||||||
|
savedAssignment.title,
|
||||||
|
savedAssignment.deadline,
|
||||||
|
savedAssignment.isCompleted
|
||||||
|
);
|
||||||
|
|
||||||
|
SetIsSaving(false);
|
||||||
|
|
||||||
|
if (!isEditMode && isSetupFlow) {
|
||||||
|
router.replace({
|
||||||
|
pathname: '/task/upsertTask',
|
||||||
|
params: {
|
||||||
|
aId: savedAssignment.aId,
|
||||||
|
flow: 'setup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
isEditMode
|
||||||
|
? 'Assignment successfully updated!'
|
||||||
|
: 'Assignment successfully created!'
|
||||||
|
);
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
|
||||||
|
|
||||||
|
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: isEditMode ? 'Edit Assignment' : 'Create Assignment',
|
||||||
|
headerTitleStyle: defaultStyles.title,
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-app-bg"
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-3xl font-bold text-text-main">
|
||||||
|
{isEditMode ? 'Edit Assignment' : 'Create Assignment'}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||||
|
{isEditMode
|
||||||
|
? 'Update this assignment and keep your subject organized.'
|
||||||
|
: 'Add a new assignment to keep your subject organized.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={labelClassName}>Title</Text>
|
||||||
|
<TextInput
|
||||||
|
testID = "assignment-title-input"
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder={
|
||||||
|
isSetupFlow ? 'e.g. Weekly problem set 3' : 'Enter assignment title'
|
||||||
|
}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={title}
|
||||||
|
onChangeText={SetTitle}
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={labelClassName}>Description</Text>
|
||||||
|
<TextInput
|
||||||
|
className={`${inputClassName} min-h-28`}
|
||||||
|
placeholder={
|
||||||
|
isSetupFlow
|
||||||
|
? 'e.g. Finish the next exercise set before Friday'
|
||||||
|
: 'Add a short description'
|
||||||
|
}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={description}
|
||||||
|
onChangeText={SetDescription}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={labelClassName}>Deadline</Text>
|
||||||
|
<TextInput
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder={isSetupFlow ? 'e.g. 2026-05-14' : 'YYYY-MM-DD'}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={deadline}
|
||||||
|
onChangeText={SetDeadline}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
|
||||||
|
isCompleted
|
||||||
|
? 'border-accent bg-accent-soft'
|
||||||
|
: 'border-app-border bg-app-subtle'
|
||||||
|
}`}
|
||||||
|
onPress={() => SetIsCompleted((current) => !current)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
|
||||||
|
isCompleted
|
||||||
|
? 'border-accent bg-accent'
|
||||||
|
: 'border-app-border bg-app-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted && (
|
||||||
|
<Text className="text-sm font-bold text-text-inverse">
|
||||||
|
✓
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold text-text-main">
|
||||||
|
Mark as completed
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm text-text-muted">
|
||||||
|
You can change this later.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
testID = "upsert-assignment-button"
|
||||||
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
|
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||||
|
}`}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text className="ml-3 text-base font-bold text-text-inverse">
|
||||||
|
{isEditMode ? 'Saving...' : 'Creating...'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
{isEditMode ? 'Save Changes' : 'Create Assignment'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-semibold text-text-secondary">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { formatDate, formatDateTime } from '@/lib/date';
|
||||||
|
import { CheckAssignmentCompletion } from '@/lib/progress';
|
||||||
|
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import type { Assignment, Task } from '@/lib/types';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
|
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from "react-native";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ViewDetailsAssignment() {
|
||||||
|
const { aId } = useLocalSearchParams<{ aId: string }>();
|
||||||
|
const [assignment, SetAssignment] = useState<Assignment | null>(null);
|
||||||
|
const [tasks, SetTasks] = useState<Task[]>([]);
|
||||||
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
|
const [subjectMeta, setSubjectMeta] = useState({
|
||||||
|
title: 'No Subject',
|
||||||
|
color: 'slate' as SubjectColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskSections = [
|
||||||
|
{ title: "Upcoming Tasks", data: tasks.filter((task) => !task.isCompleted), emptyMessage: "No upcoming tasks" },
|
||||||
|
{ title: "Completed Tasks", data: tasks.filter((task) => task.isCompleted), emptyMessage: "No completed tasks" },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null))
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
SetSession(newSession)
|
||||||
|
})
|
||||||
|
return () => sub.subscription.unsubscribe()
|
||||||
|
},
|
||||||
|
[])
|
||||||
|
|
||||||
|
const GetAssignment = async (assignmentId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.select('*')
|
||||||
|
.eq('aId', assignmentId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
Alert.alert('Assignment could not be fetched, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetAssignment(data);
|
||||||
|
|
||||||
|
if (data.sId) {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data: subjectData, error: subjectError } = await supabase
|
||||||
|
.from('subjects')
|
||||||
|
.select('title, color')
|
||||||
|
.eq('sId', data.sId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (subjectError || !subjectData) {
|
||||||
|
setSubjectMeta({
|
||||||
|
title: 'Unknown Subject',
|
||||||
|
color: 'slate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubjectMeta({
|
||||||
|
title: subjectData.title ?? 'Unknown Subject',
|
||||||
|
color: (subjectData.color as SubjectColor | undefined) ?? 'slate'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GetTasks = async (aId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase.from("tasks").select("*").eq("aId", aId);
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Tasks could not be fetched, please try again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetTasks(data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (session && aId) {
|
||||||
|
GetAssignment(aId);
|
||||||
|
GetTasks(aId);
|
||||||
|
}
|
||||||
|
}, [session, aId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteAssignment = async (aId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete Assignment",
|
||||||
|
"Are you sure you want to delete this assignment?",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
const { error } = await supabase.from("assignments").delete().eq("aId", aId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Assignment could not be deleted, please try again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert("Assignment deleted successfully!");
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteTask = async (tId: string, aId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete Task",
|
||||||
|
"Are you sure you want to delete this task?",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
const { error } = await supabase.from("tasks").delete().eq("tId", tId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Task could not be deleted, please try again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert("Task deleted successfully!");
|
||||||
|
|
||||||
|
if (aId) {
|
||||||
|
try {
|
||||||
|
await CheckAssignmentCompletion(aId);
|
||||||
|
} catch {
|
||||||
|
Alert.alert("Failed to update assignment completion state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GetTasks(aId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleTaskCompletion = async (task: Task) => {
|
||||||
|
const nextIsCompleted = !task.isCompleted;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("tasks")
|
||||||
|
.update({
|
||||||
|
isCompleted: nextIsCompleted,
|
||||||
|
lastChanged: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("tId", task.tId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Task could not be updated, please try again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CheckAssignmentCompletion(task.aId);
|
||||||
|
} catch {
|
||||||
|
Alert.alert("Failed to update assignment completion state");
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetTasks(task.aId);
|
||||||
|
await GetAssignment(task.aId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorSet = getSubjectColorSet(subjectMeta.color);
|
||||||
|
|
||||||
|
const completedTasks = tasks.filter((task) => task.isCompleted).length;
|
||||||
|
const totalTasks = tasks.length;
|
||||||
|
const remainingTasks = totalTasks - completedTasks;
|
||||||
|
|
||||||
|
const progress =
|
||||||
|
totalTasks === 0
|
||||||
|
? 0
|
||||||
|
: Math.round((completedTasks / totalTasks) * 100);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Details',
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-3xl bg-app-surface p-5"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
Assignment not found
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base text-text-secondary">
|
||||||
|
The assignment could not be loaded.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">Go back</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Assignment Details',
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full bg-app-subtle px-4 py-2"
|
||||||
|
onPress={async () => await supabase.auth.signOut()}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Logout
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionList
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 32 }}
|
||||||
|
sections={totalTasks === 0 ? [] : taskSections}
|
||||||
|
keyExtractor={(item) => item.tId}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
stickySectionHeadersEnabled={false}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View>
|
||||||
|
<View
|
||||||
|
className="rounded-3xl bg-app-surface p-5"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
{assignment.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{assignment.description ? (
|
||||||
|
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||||
|
{assignment.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="mt-4 flex-row flex-wrap">
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="mr-2 mb-2 rounded-full px-3 py-1"
|
||||||
|
style={{ backgroundColor: colorSet.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-semibold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{subjectMeta.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-secondary">
|
||||||
|
Deadline: {formatDate(assignment.deadline) || 'No deadline'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-5">
|
||||||
|
<View className="mb-2 flex-row items-center justify-between">
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Tasks completed
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-sm font-bold text-text-main">
|
||||||
|
{completedTasks}/{totalTasks}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
|
||||||
|
<View
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
backgroundColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="mt-2 text-xs font-medium text-text-secondary">
|
||||||
|
{remainingTasks === 0
|
||||||
|
? 'All tasks complete'
|
||||||
|
: `${remainingTasks} task${remainingTasks === 1 ? '' : 's'} remaining`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-1 text-xs text-text-muted">
|
||||||
|
Based only on completed tasks in this assignment.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="mt-4 text-sm text-text-muted">
|
||||||
|
Last changed: {formatDateTime(assignment.lastChanged)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-5 flex-row border-t border-app-border pt-5">
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '../assignment/upsertAssignment',
|
||||||
|
params: { aId: assignment.aId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
testID="delete-assignment-button"
|
||||||
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
|
onPress={() => DeleteAssignment(assignment.aId)}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-status-danger">
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '../task/upsertTask',
|
||||||
|
params: { aId: assignment.aId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Create Task
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderSectionHeader={({ section: { title, data } }) => (
|
||||||
|
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||||
|
<Text className="text-lg font-bold text-text-main">{title}</Text>
|
||||||
|
|
||||||
|
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-muted">
|
||||||
|
{data.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isOwner = session?.user.id === item.uId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mb-4 rounded-3xl bg-app-surface p-4"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/viewDetailsTask',
|
||||||
|
params: { tId: item.tId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className={`text-base font-bold ${
|
||||||
|
item.isCompleted ? 'text-text-secondary' : 'text-text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{item.description ? (
|
||||||
|
<Text
|
||||||
|
className="mt-1 text-sm leading-5 text-text-muted"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
|
||||||
|
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
|
||||||
|
onPress={() => ToggleTaskCompletion(item)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{item.isCompleted ? 'Reopen' : 'Complete'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '../task/upsertTask',
|
||||||
|
params: { tId: item.tId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-text-secondary">Edit</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
|
onPress={() => DeleteTask(item.tId, item.aId)}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-status-danger">
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View
|
||||||
|
className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5"
|
||||||
|
style={{ borderColor: colorSet.strong }}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
|
No tasks needed yet
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
|
Add tasks if this assignment needs smaller steps.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderSectionFooter={({ section }) =>
|
||||||
|
section.data.length === 0 ? (
|
||||||
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5" style={{ borderColor: colorSet.strong }}>
|
||||||
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
|
{section.emptyMessage}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
|
{tasks.length === 0
|
||||||
|
? 'Create the first task so this assignment turns into one clear next action.'
|
||||||
|
: 'Tasks for this assignment will show up here.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="mb-2" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
AppDev/ikt205_2026_18_study_sprint/source/app/createUser.tsx
Normal file
213
AppDev/ikt205_2026_18_study_sprint/source/app/createUser.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
KeyboardEvent,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function CreateUser() {
|
||||||
|
const [email, SetEmail] = useState('');
|
||||||
|
const [password, SetPassword] = useState('');
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
|
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
const cardLift = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
|
||||||
|
const handleKeyboardShow = (event: KeyboardEvent) => {
|
||||||
|
setIsKeyboardVisible(true);
|
||||||
|
|
||||||
|
const keyboardHeight = event.endCoordinates.height;
|
||||||
|
const liftAmount = Math.min(
|
||||||
|
Platform.OS === 'ios' ? keyboardHeight * 0.5 : keyboardHeight * 0.6,
|
||||||
|
260
|
||||||
|
);
|
||||||
|
|
||||||
|
Animated.timing(cardLift, {
|
||||||
|
toValue: -liftAmount,
|
||||||
|
duration: event.duration ?? 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyboardHide = () => {
|
||||||
|
setIsKeyboardVisible(false);
|
||||||
|
|
||||||
|
Animated.timing(cardLift, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
|
||||||
|
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showSubscription.remove();
|
||||||
|
hideSubscription.remove();
|
||||||
|
};
|
||||||
|
}, [cardLift]);
|
||||||
|
|
||||||
|
const SignUp = async () => {
|
||||||
|
if (email.trim() === '' || password.trim() === '') {
|
||||||
|
Alert.alert('All fields are required!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert(error.message, 'User could not be created, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.session) {
|
||||||
|
Alert.alert(
|
||||||
|
'Check your email',
|
||||||
|
'Your account was created. Please confirm your email before signing in.'
|
||||||
|
);
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace('/setup');
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-app-bg"
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
className="flex-1"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: isKeyboardVisible ? 24 : 64,
|
||||||
|
paddingBottom: isKeyboardVisible ? 96 : 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ translateY: cardLift }] }}>
|
||||||
|
<View className="mb-10">
|
||||||
|
<Text className="mt-5 text-4xl font-bold text-text-main">
|
||||||
|
Study Sprint
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||||
|
Organize subjects, assignments, and tasks in one calm workflow.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
Create account
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-sm leading-5 text-text-secondary">
|
||||||
|
Start your next study sprint.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
|
||||||
|
<Text className="text-sm font-bold text-text-main">
|
||||||
|
What this app does
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||||
|
Study Sprint helps you move from subject to assignment to task,
|
||||||
|
then into a focused sprint.
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-3 text-sm font-bold text-text-main">
|
||||||
|
Why an account exists
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||||
|
Your account keeps that structure and your tracked study
|
||||||
|
progress attached to you.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-6 mb-5">
|
||||||
|
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
value={email}
|
||||||
|
onChangeText={SetEmail}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Create a password so your progress follows you"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={SetPassword}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
|
isLoading ? 'bg-accent-disabled' : 'bg-accent'
|
||||||
|
}`}
|
||||||
|
onPress={SignUp}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||||
|
onPress={() => router.push('/login')}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Already have an account? Log in
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
AppDev/ikt205_2026_18_study_sprint/source/app/login.tsx
Normal file
189
AppDev/ikt205_2026_18_study_sprint/source/app/login.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { getSetupStatus } from "@/lib/setupStatus";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Keyboard, KeyboardAvoidingView, KeyboardEvent, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View } from "react-native";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, SetEmail] = useState('');
|
||||||
|
const [password, SetPassword] = useState('');
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
|
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
|
||||||
|
const handleKeyboardShow = (event: KeyboardEvent) => {
|
||||||
|
setIsKeyboardVisible(true);
|
||||||
|
|
||||||
|
const keyboardHeight = event.endCoordinates.height;
|
||||||
|
const offsetBaseline = Platform.OS === 'ios' ? 180 : 140;
|
||||||
|
const nextScrollOffset = Math.max(0, keyboardHeight - offsetBaseline);
|
||||||
|
|
||||||
|
scrollViewRef.current?.scrollTo({
|
||||||
|
y: nextScrollOffset,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyboardHide = () => {
|
||||||
|
setIsKeyboardVisible(false);
|
||||||
|
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow);
|
||||||
|
const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showSubscription.remove();
|
||||||
|
hideSubscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if(email.trim() === '' || password.trim() === '') {
|
||||||
|
Alert.alert("All fields are required!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Login failed, please check your credentials and try again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.user?.id) {
|
||||||
|
Alert.alert("Login failed, missing user session after sign-in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setupStatus = await getSetupStatus(data.user.id);
|
||||||
|
router.replace(setupStatus.isSetupComplete ? "/" : "/setup");
|
||||||
|
} catch {
|
||||||
|
router.replace("/setup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-app-bg"
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 24}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
className="flex-1"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: isKeyboardVisible ? 'flex-start' : 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: isKeyboardVisible ? 24 : 64,
|
||||||
|
paddingBottom: isKeyboardVisible ? 96 : 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mb-10">
|
||||||
|
<Text className="text-4xl font-bold text-text-main">
|
||||||
|
Study Sprint
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||||
|
Pick up where you left off.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
Log in
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-2 text-sm leading-5 text-text-secondary">
|
||||||
|
Continue your study workflow.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mt-5 rounded-2xl border border-app-border bg-app-subtle p-4">
|
||||||
|
<Text className="text-sm font-bold text-text-main">
|
||||||
|
Your study path stays with your account
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||||
|
Subjects, assignments, tasks, and tracked sprint progress follow
|
||||||
|
you after you sign in.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-5 mt-6">
|
||||||
|
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
value={email}
|
||||||
|
onChangeText={SetEmail}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="mb-2 text-sm font-semibold text-text-secondary">
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={SetPassword}
|
||||||
|
onFocus={() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
|
isLoading ? 'bg-accent-disabled' : 'bg-accent'
|
||||||
|
}`}
|
||||||
|
onPress={login}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
{isLoading ? 'Logging in...' : 'Log in'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-4 h-12 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||||
|
onPress={() => router.push('/createUser')}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Don't have an account? Sign up
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
AppDev/ikt205_2026_18_study_sprint/source/app/setup.tsx
Normal file
359
AppDev/ikt205_2026_18_study_sprint/source/app/setup.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import {
|
||||||
|
GetActiveSession,
|
||||||
|
GetSetupSprintDemoUsed,
|
||||||
|
SaveSetupSprintDemoUsed,
|
||||||
|
type ActiveSession,
|
||||||
|
} from '@/lib/asyncStorage';
|
||||||
|
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
|
||||||
|
import { getSetupStatus, type SetupStepKey } from '@/lib/setupStatus';
|
||||||
|
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
|
import { Redirect, Stack, router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
type SetupState = {
|
||||||
|
subjectId: string | null;
|
||||||
|
assignmentId: string | null;
|
||||||
|
taskId: string | null;
|
||||||
|
completedFocusSessions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETUP_STEPS = [
|
||||||
|
{
|
||||||
|
key: 'subject',
|
||||||
|
title: 'Create your first subject',
|
||||||
|
description:
|
||||||
|
'Start with one course or study area so the rest of the structure has a clear home.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'assignment',
|
||||||
|
title: 'Create your first assignment',
|
||||||
|
description:
|
||||||
|
'Add one project, exercise set, or exam-prep block inside that subject.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'task',
|
||||||
|
title: 'Create your first task',
|
||||||
|
description:
|
||||||
|
'Break the assignment into one concrete thing you can actually sit down and do.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sprint',
|
||||||
|
title: 'Start your first sprint',
|
||||||
|
description:
|
||||||
|
'Begin one focused study session so the app immediately turns into action instead of setup.',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function SetupScreen() {
|
||||||
|
const {
|
||||||
|
subjectId: subjectIdParam,
|
||||||
|
assignmentId: assignmentIdParam,
|
||||||
|
taskId: taskIdParam,
|
||||||
|
} = useLocalSearchParams<{
|
||||||
|
subjectId?: string;
|
||||||
|
assignmentId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [isAuthLoading, setIsAuthLoading] = useState(true);
|
||||||
|
const [setupState, setSetupState] = useState<SetupState>({
|
||||||
|
subjectId: subjectIdParam ?? null,
|
||||||
|
assignmentId: assignmentIdParam ?? null,
|
||||||
|
taskId: taskIdParam ?? null,
|
||||||
|
completedFocusSessions: 0,
|
||||||
|
});
|
||||||
|
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
setSession(data.session ?? null);
|
||||||
|
setIsAuthLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
setSession(newSession);
|
||||||
|
setIsAuthLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSetupState = useCallback(async () => {
|
||||||
|
if (!session?.user.id) {
|
||||||
|
setSetupState({
|
||||||
|
subjectId: null,
|
||||||
|
assignmentId: null,
|
||||||
|
taskId: null,
|
||||||
|
completedFocusSessions: 0,
|
||||||
|
});
|
||||||
|
setActiveSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [storedActiveSession, status] = await Promise.all([
|
||||||
|
GetActiveSession(),
|
||||||
|
getSetupStatus(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (storedActiveSession && storedActiveSession.endTime <= Date.now()) {
|
||||||
|
await finalizeStoredSession('expired', storedActiveSession);
|
||||||
|
setActiveSession(null);
|
||||||
|
} else {
|
||||||
|
setActiveSession(storedActiveSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetupState({
|
||||||
|
subjectId: subjectIdParam ?? status.subjectId,
|
||||||
|
assignmentId: assignmentIdParam ?? status.assignmentId,
|
||||||
|
taskId: taskIdParam ?? status.taskId,
|
||||||
|
completedFocusSessions: status.completedFocusSessions,
|
||||||
|
});
|
||||||
|
}, [assignmentIdParam, session?.user.id, subjectIdParam, taskIdParam]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
void loadSetupState();
|
||||||
|
}, [loadSetupState])
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentStep: SetupStepKey = (() => {
|
||||||
|
if (!setupState.subjectId) {
|
||||||
|
return 'subject';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setupState.assignmentId) {
|
||||||
|
return 'assignment';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setupState.taskId) {
|
||||||
|
return 'task';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sprint';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isSetupComplete =
|
||||||
|
setupState.taskId !== null && setupState.completedFocusSessions > 0;
|
||||||
|
|
||||||
|
const handlePrimaryAction = useCallback(async () => {
|
||||||
|
if (isSetupComplete) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'subject') {
|
||||||
|
router.push({
|
||||||
|
pathname: '/subject/upsertSubject',
|
||||||
|
params: { flow: 'setup' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'assignment' && setupState.subjectId) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/assignment/upsertAssignment',
|
||||||
|
params: {
|
||||||
|
sId: setupState.subjectId,
|
||||||
|
flow: 'setup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'task' && setupState.assignmentId) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/upsertTask',
|
||||||
|
params: {
|
||||||
|
aId: setupState.assignmentId,
|
||||||
|
flow: 'setup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setupState.taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshActiveSession = await GetActiveSession();
|
||||||
|
|
||||||
|
if (freshActiveSession && freshActiveSession.endTime > Date.now()) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: freshActiveSession.taskId
|
||||||
|
? { tId: freshActiveSession.taskId }
|
||||||
|
: {
|
||||||
|
sessionType: freshActiveSession.sessionType,
|
||||||
|
durationMinutes: String(
|
||||||
|
Math.max(1, Math.round(freshActiveSession.durationSeconds / 60))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freshActiveSession) {
|
||||||
|
await finalizeStoredSession('expired', freshActiveSession);
|
||||||
|
setActiveSession(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUseDemoSprint = session?.user.id
|
||||||
|
? !(await GetSetupSprintDemoUsed(session.user.id))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (shouldUseDemoSprint && session?.user.id) {
|
||||||
|
await SaveSetupSprintDemoUsed(session.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: shouldUseDemoSprint
|
||||||
|
? {
|
||||||
|
tId: setupState.taskId,
|
||||||
|
durationSeconds: '5',
|
||||||
|
onboardingDemo: 'true',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tId: setupState.taskId,
|
||||||
|
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [currentStep, isSetupComplete, session?.user.id, setupState]);
|
||||||
|
|
||||||
|
const primaryLabel = isSetupComplete
|
||||||
|
? 'Go to dashboard'
|
||||||
|
: currentStep === 'subject'
|
||||||
|
? 'Create first subject'
|
||||||
|
: currentStep === 'assignment'
|
||||||
|
? 'Create first assignment'
|
||||||
|
: currentStep === 'task'
|
||||||
|
? 'Create first task'
|
||||||
|
: activeSession
|
||||||
|
? 'Open active sprint'
|
||||||
|
: 'Start first sprint';
|
||||||
|
|
||||||
|
if (isAuthLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return <Redirect href="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Guided Setup',
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full bg-app-subtle px-4 py-2"
|
||||||
|
onPress={async () => await supabase.auth.signOut()}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Logout
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 32,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-xs font-bold uppercase tracking-[0.8px] text-text-muted">
|
||||||
|
First-time setup
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-3xl font-bold text-text-main">
|
||||||
|
Build one simple study path
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||||
|
You only need one subject, one assignment, one task, and one sprint to
|
||||||
|
make the app useful.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-6 gap-3">
|
||||||
|
{SETUP_STEPS.map((step, index) => {
|
||||||
|
const isDone =
|
||||||
|
step.key === 'subject'
|
||||||
|
? Boolean(setupState.subjectId)
|
||||||
|
: step.key === 'assignment'
|
||||||
|
? Boolean(setupState.assignmentId)
|
||||||
|
: step.key === 'task'
|
||||||
|
? Boolean(setupState.taskId)
|
||||||
|
: isSetupComplete;
|
||||||
|
const isCurrent = !isDone && currentStep === step.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={step.key}
|
||||||
|
className={`rounded-3xl border p-4 ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-accent bg-accent-soft'
|
||||||
|
: 'border-app-border bg-app-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start">
|
||||||
|
<View
|
||||||
|
className={`mr-3 h-8 w-8 items-center justify-center rounded-full ${
|
||||||
|
isDone ? 'bg-accent' : isCurrent ? 'bg-text-main' : 'bg-app-subtle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-sm font-bold ${
|
||||||
|
isDone || isCurrent ? 'text-text-inverse' : 'text-text-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDone ? '✓' : index + 1}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-lg font-bold text-text-main">
|
||||||
|
{step.title}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||||
|
{step.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
{isSetupComplete
|
||||||
|
? 'You have already completed at least one focus sprint.'
|
||||||
|
: currentStep === 'sprint'
|
||||||
|
? 'The structure is ready. The next step is to actually begin a sprint.'
|
||||||
|
: 'Follow the next step below. The rest of the app will make more sense once that path exists.'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={handlePrimaryAction}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
{primaryLabel}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function SubjectLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="upsertSubject" />
|
||||||
|
<Stack.Screen name="viewDetailsSubject" options={{ title: "Subject Details" }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { defaultStyles } from '@/constants/defaultStyles';
|
||||||
|
import { SUBJECT_COLOR_KEYS, SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import type { Subject } from '@/lib/types';
|
||||||
|
import { router, Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
|
||||||
|
export default function UpsertSubject() {
|
||||||
|
const { sId, flow } = useLocalSearchParams<{ sId?: string; flow?: string }>();
|
||||||
|
const isEditMode = Boolean(sId);
|
||||||
|
const isSetupFlow = flow === 'setup';
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [color, setColor] = useState<SubjectColor>('blue');
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditMode || !sId) return;
|
||||||
|
|
||||||
|
const loadSubject = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('subjects')
|
||||||
|
.select('*')
|
||||||
|
.eq('sId', sId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (error || !data ) {
|
||||||
|
Alert.alert('Subject could not be loaded, please try again');
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = data as Subject;
|
||||||
|
|
||||||
|
setTitle(subject.title ?? '');
|
||||||
|
setDescription(subject.description ?? '');
|
||||||
|
setIsActive(subject.isActive ?? true);
|
||||||
|
setColor(subject.color ?? 'blue');
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSubject();
|
||||||
|
}, [isEditMode, sId]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (title.trim() === '') {
|
||||||
|
Alert.alert('Title is required!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error: userError } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !data.user) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.trim(),
|
||||||
|
description : description.trim(),
|
||||||
|
isActive,
|
||||||
|
color,
|
||||||
|
lastChanged: new Date().toISOString(),
|
||||||
|
uId: data.user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isEditMode && sId
|
||||||
|
? await supabase.from('subjects').update(payload).eq('sId', sId)
|
||||||
|
: await supabase.from('subjects').insert(payload).select().single();
|
||||||
|
|
||||||
|
setIsSaving(false);
|
||||||
|
|
||||||
|
if(result.error) {
|
||||||
|
Alert.alert(
|
||||||
|
isEditMode
|
||||||
|
? 'Subject could not be updated, please try again'
|
||||||
|
: 'Subject could not be created, please try again'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditMode && isSetupFlow && result.data?.sId) {
|
||||||
|
router.replace({
|
||||||
|
pathname: '/assignment/upsertAssignment',
|
||||||
|
params: {
|
||||||
|
sId: result.data.sId,
|
||||||
|
flow: 'setup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
isEditMode ? 'Subject updated successfully!' : 'Subject created successfully!'
|
||||||
|
);
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
|
||||||
|
|
||||||
|
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
|
||||||
|
|
||||||
|
const selectedColor = useMemo(() => SUBJECT_COLORS[color], [color]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options= {{
|
||||||
|
title: isEditMode ? 'Edit Subject' : 'Create Subject',
|
||||||
|
headerTitleStyle: defaultStyles.title,
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-app-bg"
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-3xl font-bold text-text-main">
|
||||||
|
{isEditMode ? 'Edit Subject' : 'Create Subject'}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||||
|
{isEditMode? ' Update this subject and keep your study structure organized.'
|
||||||
|
: 'Add a subject to organize your assignments and study tasks.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={labelClassName}>Title</Text>
|
||||||
|
<TextInput className={inputClassName}
|
||||||
|
placeholder={isSetupFlow ? 'e.g. Algorithms' : 'Enter subject title'}
|
||||||
|
testID = "subject-title-input"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className ="mb-5">
|
||||||
|
<Text className={labelClassName}>Description</Text>
|
||||||
|
<TextInput
|
||||||
|
className={`${inputClassName} min-h-28`}
|
||||||
|
placeholder={isSetupFlow ? 'e.g. Lectures, problem sets, and exam prep' : 'Add a short description'}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className={labelClassName}>Color</Text>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className={labelClassName}>Preview</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-3xl bg-app-surface p-4"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: selectedColor.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<View
|
||||||
|
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: selectedColor.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold"
|
||||||
|
style={{ color: selectedColor.strong }}
|
||||||
|
>
|
||||||
|
{title.trim().charAt(0).toUpperCase() || 'S'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold text-text-main"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title.trim() || 'Subject Preview'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
className="mt-1 text-sm leading-5 text-text-secondary"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{description.trim() || 'This color will be used as the subject card accent.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="ml-3">
|
||||||
|
<View
|
||||||
|
className="rounded-full px-3 py-1"
|
||||||
|
style={{ backgroundColor: selectedColor.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-semibold"
|
||||||
|
style={{ color: selectedColor.strong }}
|
||||||
|
>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row flex-wrap">
|
||||||
|
{SUBJECT_COLOR_KEYS.map((colorKey) => {
|
||||||
|
const colorOption = SUBJECT_COLORS[colorKey];
|
||||||
|
const isSelected = color === colorKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={colorKey}
|
||||||
|
onPress={() => setColor(colorKey)}
|
||||||
|
className="mr-3 mb-3 rounded-2xl border border-app-border bg-app--surface p-2"
|
||||||
|
style={{
|
||||||
|
borderColor: isSelected
|
||||||
|
? colorOption.strong
|
||||||
|
: '#FFFFFF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<View
|
||||||
|
className="mr-2 h-8 w-8 rounded-xl"
|
||||||
|
style={{ backgroundColor: colorOption.strong }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
color: isSelected
|
||||||
|
? colorOption.strong
|
||||||
|
: '#52616B',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{colorOption.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setIsActive((state) => !state)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
|
||||||
|
isActive
|
||||||
|
? 'border-accent bg-accent-soft'
|
||||||
|
: 'border-app-border bg-app-subtle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
|
||||||
|
isActive
|
||||||
|
? 'border-accent bg-accent'
|
||||||
|
: 'border-app-border bg-app-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold text-text-main">
|
||||||
|
Active subject
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm text-text-muted">
|
||||||
|
Active subjects appear in your main study workflow.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
testID = "upsert-subject-button"
|
||||||
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
|
isSaving
|
||||||
|
? 'bg-accent-disabled'
|
||||||
|
: 'bg-accent'
|
||||||
|
}`}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text className="ml-3 text-base font-bold-text-text-inverse">
|
||||||
|
{isEditMode ? 'Saving...' : 'Creating...'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
{isEditMode ? 'Save Changes' : 'Create Subject'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-semibold text-text-secondary">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
import { formatDate, formatDateTime } from '@/lib/date';
|
||||||
|
import { SUBJECT_COLORS, type SubjectColor } from '@/lib/subjectColors';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import type { Assignment } from '@/lib/types';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
|
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, Pressable, SectionList, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export type Subject = {
|
||||||
|
sId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
lastChanged: string;
|
||||||
|
uId: string;
|
||||||
|
color: SubjectColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ViewDetailsSubject() {
|
||||||
|
const { sId } = useLocalSearchParams<{ sId: string }>();
|
||||||
|
const [subject, SetSubject] = useState<Subject | null>(null);
|
||||||
|
const [assignments, SetAssignments] = useState<Assignment[]>([]);
|
||||||
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const assignmentSections = [
|
||||||
|
{
|
||||||
|
title: 'Active Assignments',
|
||||||
|
data: assignments.filter((assignment) => !assignment.isCompleted),
|
||||||
|
emptyMessage: 'No active assignments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Completed Assignments',
|
||||||
|
data: assignments.filter((assignment) => assignment.isCompleted),
|
||||||
|
emptyMessage: 'No completed assignments',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
SetSession(newSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const GetSubject = async (subjectId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('subjects')
|
||||||
|
.select('*')
|
||||||
|
.eq('sId', subjectId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Subject could not be fetched, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSubject((data as Subject) ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GetAssignments = async (subjectId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.select('*')
|
||||||
|
.eq('sId', subjectId)
|
||||||
|
.order('deadline', { ascending: true });
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Assignments could not be fetched, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetAssignments(data ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToggleAssignmentCompletion = async (assignment: Assignment) => {
|
||||||
|
const nextIsCompleted = !assignment.isCompleted;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.update({
|
||||||
|
isCompleted: nextIsCompleted,
|
||||||
|
lastChanged: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('aId', assignment.aId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Assignment could not be updated, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetAssignments(assignment.sId);
|
||||||
|
await GetSubject(assignment.sId);
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!session || !sId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsLoading(true);
|
||||||
|
SetSubject(null);
|
||||||
|
|
||||||
|
Promise.all([GetSubject(sId), GetAssignments(sId)]).finally(() => {
|
||||||
|
SetIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [session, sId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteSubject = async (subjectId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Subject',
|
||||||
|
'Are you sure you want to delete this subject?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('subjects')
|
||||||
|
.delete()
|
||||||
|
.eq('sId', subjectId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Subject could not be deleted, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert('Subject deleted successfully!');
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAssignment = async (assignmentId: string, subjectId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Assignment',
|
||||||
|
'Are you sure you want to delete this assignment?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.delete()
|
||||||
|
.eq('aId', assignmentId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Assignment could not be deleted, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetAssignments(subjectId);
|
||||||
|
await GetSubject(subjectId);
|
||||||
|
|
||||||
|
Alert.alert('Assignment deleted successfully!');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedAssignments = assignments.filter((assignment) => assignment.isCompleted).length;
|
||||||
|
const totalAssignments = assignments.length;
|
||||||
|
const remainingAssignments = totalAssignments - completedAssignments;
|
||||||
|
|
||||||
|
const progress =
|
||||||
|
assignments.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.round((completedAssignments / totalAssignments) * 100);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Subject Details',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#2563eb" />
|
||||||
|
<Text className="mt-4 text-base font-semibold text-text-secondary">
|
||||||
|
Loading subject...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Subject Details',
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
Subject not found
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base text-text-secondary">
|
||||||
|
The subject could not be loaded.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Go back
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorKey: SubjectColor = subject.color ?? 'slate';
|
||||||
|
const colorSet = SUBJECT_COLORS[colorKey];
|
||||||
|
|
||||||
|
const firstLetter = subject.title?.trim().charAt(0).toUpperCase() || 'S';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Subject Details',
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full bg-app-subtle px-4 py-2"
|
||||||
|
onPress={async () => await supabase.auth.signOut()}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Logout
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionList
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 32,
|
||||||
|
}}
|
||||||
|
sections={totalAssignments === 0 ? [] : assignmentSections}
|
||||||
|
keyExtractor={(item) => item.aId}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
stickySectionHeadersEnabled={false}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View>
|
||||||
|
<View
|
||||||
|
className="rounded-3xl bg-app-surface p-5"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<View
|
||||||
|
className="mr-3 h-12 w-12 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: colorSet.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-base font-bold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{firstLetter}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
{subject.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{subject.description ? (
|
||||||
|
<Text className="mt-1 text-sm leading-5 text-text-secondary">
|
||||||
|
{subject.description}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="mt-1 text-sm leading-5 text-text-muted">
|
||||||
|
No description added.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="ml-3">
|
||||||
|
<View
|
||||||
|
className="rounded-full px-3 py-1"
|
||||||
|
style={{ backgroundColor: colorSet.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-semibold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{subject.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-5">
|
||||||
|
<View className="mb-2 flex-row items-center justify-between">
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Assignment Progress
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-sm font-bold text-text-main">
|
||||||
|
{completedAssignments}/{totalAssignments}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="h-3 overflow-hidden rounded-full bg-app-subtle">
|
||||||
|
<View
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
backgroundColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="mt-2 text-xs font-medium text-text-secondary">
|
||||||
|
{remainingAssignments === 0
|
||||||
|
? 'All assignments complete'
|
||||||
|
: `${remainingAssignments} assignment${
|
||||||
|
remainingAssignments === 1 ? '' : 's'
|
||||||
|
} remaining`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-1 text-xs text-text-muted">
|
||||||
|
Based only on completed assignments in this subject.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="mt-4 text-sm text-text-muted">
|
||||||
|
Last changed: {formatDateTime(subject.lastChanged)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mt-5 flex-row border-t border-app-border pt-5">
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '../subject/upsertSubject',
|
||||||
|
params: { sId: subject.sId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-text-secondary">
|
||||||
|
Edit
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
testID="delete-subject-button"
|
||||||
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
|
onPress={() => DeleteSubject(subject.sId)}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-status-danger">
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mb-6 mt-5 h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '../assignment/upsertAssignment',
|
||||||
|
params: { sId: subject.sId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Add Assignment
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderSectionHeader={({ section: { title, data } }) => (
|
||||||
|
<View className="mb-3 mt-2 flex-row items-center justify-between">
|
||||||
|
<Text className="text-lg font-bold text-text-main">{title}</Text>
|
||||||
|
|
||||||
|
<View className="rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-muted">
|
||||||
|
{data.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isOwner = session?.user.id === item.uId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="mb-4 rounded-3xl border border-app-border bg-app-surface p-4"
|
||||||
|
style={{
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<Pressable
|
||||||
|
className="flex-1"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/assignment/viewDetailsAssignment',
|
||||||
|
params: { aId: item.aId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className={`text-base font-bold ${
|
||||||
|
item.isCompleted ? 'text-text-secondary' : 'text-text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{item.description ? (
|
||||||
|
<Text
|
||||||
|
className="mt-1 text-sm leading-5 text-text-muted"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text className="mt-2 text-sm text-text-secondary">
|
||||||
|
Deadline: {formatDate(item.deadline)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<View className="mt-4 flex-row border-t border-app-border pt-4">
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl py-3"
|
||||||
|
style={{ backgroundColor: item.isCompleted ? '#EFEBE3' : colorSet.soft }}
|
||||||
|
onPress={() => ToggleAssignmentCompletion(item)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{item.isCompleted ? 'Reopen' : 'Complete'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '../assignment/upsertAssignment',
|
||||||
|
params: { aId: item.aId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-text-secondary">
|
||||||
|
Edit
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
|
onPress={() => DeleteAssignment(item.aId, item.sId)}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-status-danger">
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
|
No assignments yet
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
|
Add one when this subject has work to track.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderSectionFooter={({ section }) =>
|
||||||
|
section.data.length === 0 ? (
|
||||||
|
<View className="mb-6 rounded-3xl border border-app-border bg-app-surface p-5">
|
||||||
|
<Text className="text-center text-base font-semibold text-text-secondary">
|
||||||
|
{section.emptyMessage}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-center text-sm text-text-muted">
|
||||||
|
{assignments.length === 0
|
||||||
|
? 'Create the first assignment to give this subject a real study path.'
|
||||||
|
: 'Assignments for this subject will show up here.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="mb-2" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function TaskLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="upsertTask" options={{ title: "Create Task" }} />
|
||||||
|
<Stack.Screen name="viewDetailsTask" options={{ title: "Task Details" }} />
|
||||||
|
<Stack.Screen name='timer' options={{title: 'Sprint'}} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
1483
AppDev/ikt205_2026_18_study_sprint/source/app/task/timer.tsx
Normal file
1483
AppDev/ikt205_2026_18_study_sprint/source/app/task/timer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
|||||||
|
import { defaultStyles } from '@/constants/defaultStyles';
|
||||||
|
import { SaveSetupSprintDemoUsed } from '@/lib/asyncStorage';
|
||||||
|
import { CheckAssignmentCompletion } from '@/lib/progress';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import type { Task } from '@/lib/types';
|
||||||
|
import { router, Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function UpsertTask() {
|
||||||
|
const { tId, aId: routeAId, flow } = useLocalSearchParams<{
|
||||||
|
tId?: string;
|
||||||
|
aId?: string;
|
||||||
|
flow?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isEditMode = Boolean(tId);
|
||||||
|
const isSetupFlow = flow === 'setup';
|
||||||
|
|
||||||
|
const [title, SetTitle] = useState('');
|
||||||
|
const [description, SetDescription] = useState('');
|
||||||
|
const [isCompleted, SetIsCompleted] = useState(false);
|
||||||
|
const [assignmentId, SetAssignmentId] = useState<string | null>(routeAId ?? null);
|
||||||
|
|
||||||
|
const [isLoading, SetIsLoading] = useState(isEditMode);
|
||||||
|
const [isSaving, SetIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditMode || !tId) {
|
||||||
|
SetIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTask = async () => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('tId', tId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
SetIsLoading(false);
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
Alert.alert('Task could not be loaded, please try again');
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = data as Task;
|
||||||
|
|
||||||
|
SetTitle(task.title ?? '');
|
||||||
|
SetDescription(task.description ?? '');
|
||||||
|
SetIsCompleted(task.isCompleted ?? false);
|
||||||
|
SetAssignmentId(task.aId ?? routeAId ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTask();
|
||||||
|
}, [isEditMode, tId, routeAId]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (title.trim() === '') {
|
||||||
|
Alert.alert('Title is required!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error: userError } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !data.user) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignmentId) {
|
||||||
|
Alert.alert('Missing assignment', 'This task is not linked to an assignment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsSaving(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
isCompleted,
|
||||||
|
lastChanged: new Date().toISOString(),
|
||||||
|
uId: data.user.id,
|
||||||
|
aId: assignmentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isEditMode && tId
|
||||||
|
? await supabase.from('tasks').update(payload).eq('tId', tId)
|
||||||
|
: await supabase.from('tasks').insert(payload).select().single();
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
SetIsSaving(false);
|
||||||
|
Alert.alert(
|
||||||
|
isEditMode
|
||||||
|
? 'Task could not be updated, please try again'
|
||||||
|
: 'Task could not be created, please try again'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CheckAssignmentCompletion(assignmentId);
|
||||||
|
} catch {
|
||||||
|
SetIsSaving(false);
|
||||||
|
Alert.alert('Failed to update assignment completion state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetIsSaving(false);
|
||||||
|
|
||||||
|
if (!isEditMode && isSetupFlow && result.data?.tId) {
|
||||||
|
await SaveSetupSprintDemoUsed(data.user.id);
|
||||||
|
router.replace({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: {
|
||||||
|
tId: result.data.tId,
|
||||||
|
durationSeconds: '5',
|
||||||
|
onboardingDemo: 'true',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
isEditMode ? 'Task successfully updated!' : 'Task successfully created!'
|
||||||
|
);
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'rounded-2xl border border-app-border bg-app-subtle px-4 py-3 text-base text-text-main';
|
||||||
|
|
||||||
|
const labelClassName = 'mb-2 text-sm font-semibold text-text-secondary';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: isEditMode ? 'Edit Task' : 'Create Task',
|
||||||
|
headerTitleStyle: defaultStyles.title,
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1 bg-app-bg"
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-3xl font-bold text-text-main">
|
||||||
|
{isEditMode ? 'Edit Task' : 'Create Task'}
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base leading-6 text-text-secondary">
|
||||||
|
{isEditMode
|
||||||
|
? 'Update this task and keep your assignment moving forward.'
|
||||||
|
: 'Add a small step to move this assignment forward.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="rounded-3xl border border-app-border bg-app-surface p-5 shadow-sm">
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={labelClassName}>Title</Text>
|
||||||
|
<TextInput
|
||||||
|
testID="task-title-input"
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder={isSetupFlow ? 'e.g. Solve questions 1-3' : 'Enter task title'}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={title}
|
||||||
|
onChangeText={SetTitle}
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={labelClassName}>Description</Text>
|
||||||
|
<TextInput
|
||||||
|
className={`${inputClassName} min-h-28`}
|
||||||
|
placeholder={
|
||||||
|
isSetupFlow
|
||||||
|
? 'e.g. Work through the first three tasks without notes'
|
||||||
|
: 'Add a short description'
|
||||||
|
}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={description}
|
||||||
|
onChangeText={SetDescription}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => SetIsCompleted((state) => !state)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`mb-6 flex-row items-center rounded-2xl border p-4 ${
|
||||||
|
isCompleted
|
||||||
|
? 'border-accent bg-accent-soft'
|
||||||
|
: 'border-app-border bg-app-subtle'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`mr-3 h-6 w-6 items-center justify-center rounded-md border-2 ${
|
||||||
|
isCompleted
|
||||||
|
? 'border-accent bg-accent'
|
||||||
|
: 'border-app-border bg-app-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted && (
|
||||||
|
<Text className="text-sm font-bold text-text-inverse">
|
||||||
|
✓
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-semibold text-text-main">
|
||||||
|
Mark as completed
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-sm text-text-muted">
|
||||||
|
You can change this later.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
testID="upsert-task-button"
|
||||||
|
className={`h-14 items-center justify-center rounded-2xl ${
|
||||||
|
isSaving ? 'bg-accent-disabled' : 'bg-accent'
|
||||||
|
}`}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text className="ml-3 text-base font-bold text-text-inverse">
|
||||||
|
{isEditMode ? 'Saving...' : 'Creating...'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
{isEditMode ? 'Save Changes' : 'Create Task'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-3 h-14 items-center justify-center rounded-2xl border border-app-border bg-app-subtle"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-semibold text-text-secondary">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { GetActiveSession } from '@/lib/asyncStorage';
|
||||||
|
import { formatDateTime } from '@/lib/date';
|
||||||
|
import { CheckAssignmentCompletion } from '@/lib/progress';
|
||||||
|
import { DEFAULT_FOCUS_DURATION_MINUTES } from '@/lib/sessionDefaults';
|
||||||
|
import { finalizeStoredSession } from '@/lib/sessionLifecycle';
|
||||||
|
import { getSubjectColorSet, type SubjectColor } from '@/lib/subjectColors';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import type { Task } from '@/lib/types';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
|
import { router, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
function formatTrackedTime(totalSeconds: number) {
|
||||||
|
if (totalSeconds <= 0) {
|
||||||
|
return '0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewDetailsTask() {
|
||||||
|
const { tId } = useLocalSearchParams<{ tId: string }>();
|
||||||
|
|
||||||
|
const [task, SetTask] = useState<Task | null>(null);
|
||||||
|
const [session, SetSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, SetIsLoading] = useState(false);
|
||||||
|
const [completedFocusSessions, setCompletedFocusSessions] = useState(0);
|
||||||
|
const [contextMeta, setContextMeta] = useState({
|
||||||
|
subjectTitle: 'No Subject',
|
||||||
|
assignmentTitle: 'No Assignment',
|
||||||
|
subjectColor: 'slate' as SubjectColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data }) => SetSession(data.session ?? null));
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
SetSession(newSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTaskStudyActivity = useCallback(async (taskId: string, userId: string) => {
|
||||||
|
const { count, error } = await supabase
|
||||||
|
.from('sprint_sessions')
|
||||||
|
.select('sessionId', { count: 'exact', head: true })
|
||||||
|
.eq('taskId', taskId)
|
||||||
|
.eq('userId', userId)
|
||||||
|
.eq('sessionType', 'focus')
|
||||||
|
.eq('status', 'completed');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setCompletedFocusSessions(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompletedFocusSessions(count ?? 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const GetTask = useCallback(async (taskId: string) => {
|
||||||
|
SetIsLoading(true);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.select('*')
|
||||||
|
.eq('tId', taskId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
SetTask(null);
|
||||||
|
setContextMeta({
|
||||||
|
subjectTitle: 'Unknown Subject',
|
||||||
|
assignmentTitle: 'Unknown Assignment',
|
||||||
|
subjectColor: 'slate',
|
||||||
|
});
|
||||||
|
setCompletedFocusSessions(0);
|
||||||
|
SetIsLoading(false);
|
||||||
|
Alert.alert('Task could not be fetched, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetTask(data);
|
||||||
|
await loadTaskStudyActivity(taskId, data.uId);
|
||||||
|
|
||||||
|
let nextContextMeta = {
|
||||||
|
subjectTitle: 'Unknown Subject',
|
||||||
|
assignmentTitle: 'Unknown Assignment',
|
||||||
|
subjectColor: 'slate' as SubjectColor,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.aId) {
|
||||||
|
const { data: assignmentData, error: assignmentError } = await supabase
|
||||||
|
.from('assignments')
|
||||||
|
.select('title, sId')
|
||||||
|
.eq('aId', data.aId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!assignmentError && assignmentData) {
|
||||||
|
nextContextMeta.assignmentTitle = assignmentData.title ?? 'Unknown Assignment';
|
||||||
|
|
||||||
|
if (assignmentData.sId) {
|
||||||
|
const { data: subjectData, error: subjectError } = await supabase
|
||||||
|
.from('subjects')
|
||||||
|
.select('title, color')
|
||||||
|
.eq('sId', assignmentData.sId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!subjectError && subjectData) {
|
||||||
|
nextContextMeta = {
|
||||||
|
subjectTitle: subjectData.title ?? 'Unknown Subject',
|
||||||
|
assignmentTitle: assignmentData.title ?? 'Unknown Assignment',
|
||||||
|
subjectColor: (subjectData.color as SubjectColor | undefined) ?? 'slate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContextMeta(nextContextMeta);
|
||||||
|
SetIsLoading(false);
|
||||||
|
}, [loadTaskStudyActivity]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (session && tId) {
|
||||||
|
void GetTask(tId);
|
||||||
|
}
|
||||||
|
}, [GetTask, session, tId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSprintStart = async () => {
|
||||||
|
const activeSession = await GetActiveSession();
|
||||||
|
|
||||||
|
if (!activeSession) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: {
|
||||||
|
tId: task?.tId,
|
||||||
|
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsLeft = Math.ceil((activeSession.endTime - Date.now()) / 1000);
|
||||||
|
|
||||||
|
if (secondsLeft <= 0) {
|
||||||
|
await finalizeStoredSession('expired', activeSession);
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: {
|
||||||
|
tId: task?.tId,
|
||||||
|
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSession.taskId === task?.tId) {
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: {
|
||||||
|
tId: activeSession.taskId ?? undefined,
|
||||||
|
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Active session in progress',
|
||||||
|
`End the current session and start a new ${DEFAULT_FOCUS_DURATION_MINUTES} minute sprint on this task?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Start new sprint',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await finalizeStoredSession('cancelled', activeSession);
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/timer',
|
||||||
|
params: {
|
||||||
|
tId: task?.tId,
|
||||||
|
durationMinutes: String(DEFAULT_FOCUS_DURATION_MINUTES),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteTask = async (taskId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Task',
|
||||||
|
'Are you sure you want to delete this task?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tasks')
|
||||||
|
.delete()
|
||||||
|
.eq('tId', taskId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert('Task could not be deleted, please try again');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aId = task?.aId;
|
||||||
|
|
||||||
|
if (aId) {
|
||||||
|
try {
|
||||||
|
await CheckAssignmentCompletion(aId);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Failed to update assignment completion state');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert('Task deleted successfully!');
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorSet = getSubjectColorSet(contextMeta.subjectColor);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-app-bg">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Task Details',
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full bg-app-subtle px-4 py-2"
|
||||||
|
onPress={async () => await supabase.auth.signOut()}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Logout
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-3xl bg-app-surface p-5"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-2xl font-bold text-text-main">
|
||||||
|
Task not found
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-2 text-base text-text-secondary">
|
||||||
|
The task could not be loaded.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="mt-5 h-12 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Go back
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = session?.user.id === task.uId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-app-bg px-5 pt-6">
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: 'Task Details',
|
||||||
|
headerTitleAlign: 'center',
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-full bg-app-subtle px-4 py-2"
|
||||||
|
onPress={async () => await supabase.auth.signOut()}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Logout
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-3xl bg-app-surface p-5"
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorSet.strong,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start">
|
||||||
|
<View
|
||||||
|
className="mr-3 mt-1 h-6 w-6 items-center justify-center rounded-md border-2"
|
||||||
|
style={{
|
||||||
|
borderColor: task.isCompleted ? colorSet.strong : '#DDD6C8',
|
||||||
|
backgroundColor: task.isCompleted ? colorSet.strong : '#EFEBE3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.isCompleted ? (
|
||||||
|
<Text className="text-sm font-bold text-text-inverse">✓</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
task.isCompleted ? 'text-text-secondary' : 'text-text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{task.description ? (
|
||||||
|
<Text className="mt-3 text-base leading-6 text-text-secondary">
|
||||||
|
{task.description}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="mt-3 text-base text-text-muted">
|
||||||
|
No description added.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="mt-4 flex-row flex-wrap">
|
||||||
|
<View
|
||||||
|
className="mr-2 mb-2 rounded-full px-3 py-1"
|
||||||
|
style={{ backgroundColor: colorSet.soft }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs font-semibold"
|
||||||
|
style={{ color: colorSet.strong }}
|
||||||
|
>
|
||||||
|
{contextMeta.subjectTitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-secondary">
|
||||||
|
{contextMeta.assignmentTitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mr-2 mb-2 rounded-full bg-app-subtle px-3 py-1">
|
||||||
|
<Text className="text-xs font-semibold text-text-secondary">
|
||||||
|
Status: {task.isCompleted ? 'Completed' : 'Not completed'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-5 rounded-2xl bg-app-subtle p-4">
|
||||||
|
<Text className="text-sm font-semibold text-text-secondary">
|
||||||
|
Study activity
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-xs leading-5 text-text-muted">
|
||||||
|
This tracks focused work on the task separately from whether the task is marked completed.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mt-4 flex-row gap-3">
|
||||||
|
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||||
|
Focus time
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||||
|
{formatTrackedTime(task.totalTimeInSeconds ?? 0)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 rounded-2xl bg-app-surface px-4 py-3">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-[0.6px] text-text-muted">
|
||||||
|
Completed sessions
|
||||||
|
</Text>
|
||||||
|
<Text className="mt-1 text-lg font-bold text-text-main">
|
||||||
|
{completedFocusSessions}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="mt-2 text-sm text-text-muted">
|
||||||
|
Last changed: {formatDateTime(task.lastChanged)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isOwner ? (
|
||||||
|
<View className="mt-5 border-t border-app-border pt-5">
|
||||||
|
<Pressable
|
||||||
|
className="h-14 items-center justify-center rounded-2xl bg-accent"
|
||||||
|
onPress={handleSprintStart}
|
||||||
|
>
|
||||||
|
<Text className="text-base font-bold text-text-inverse">
|
||||||
|
Start Sprint
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text className="mt-3 text-sm text-text-muted">
|
||||||
|
Starts a {DEFAULT_FOCUS_DURATION_MINUTES} minute focus sprint for this task.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mt-4 flex-row">
|
||||||
|
<Pressable
|
||||||
|
className="mr-3 flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-subtle py-3"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: '/task/upsertTask',
|
||||||
|
params: { tId: task.tId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-text-secondary">
|
||||||
|
Edit
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="flex-1 items-center justify-center rounded-2xl border border-app-border bg-app-surface py-3"
|
||||||
|
onPress={() => DeleteTask(task.tId)}
|
||||||
|
>
|
||||||
|
<Text className="text-sm font-bold text-status-danger">
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user