diff --git a/src/api/__tests__/live-issues.test.ts b/src/api/__tests__/live-issues.test.ts
new file mode 100644
index 0000000..52834ed
--- /dev/null
+++ b/src/api/__tests__/live-issues.test.ts
@@ -0,0 +1,155 @@
+/**
+ * @jest-environment node
+ */
+import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth';
+import {
+ createIssueTeamsTeamIdIssuesPost,
+ deleteIssueIssuesIssueIdDelete,
+ getIssueIssuesIssueIdGet,
+ listIssuesTeamsTeamIdIssuesGet,
+ updateIssueIssuesIssueIdPatch,
+} from '@/api/generated/endpoints/issues/issues';
+import { createLabelTeamsTeamIdLabelsPost } from '@/api/generated/endpoints/labels/labels';
+import { createProjectTeamsTeamIdProjectsPost } from '@/api/generated/endpoints/projects/projects';
+import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses';
+import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams';
+import type { StatusRead, TeamRead } from '@/api/generated/models';
+import { setInstanceUrl } from '@/api/instance';
+import { persistToken } from '@/auth/session';
+import { ESTIMATE_SCALE } from '@/issues/use-issue-properties';
+
+/**
+ * Creating and editing issues against a real instance.
+ *
+ * The acceptance criterion for #6 is that an issue created on mobile is
+ * indistinguishable from a web-created one, so these assert the identifier
+ * shape and that every property round-trips -- including the two that mean
+ * something by being absent.
+ *
+ * SOFTTRACK_LIVE_URL=http://localhost:8000 npm test
+ */
+const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
+const describeLive = LIVE_URL ? describe : describe.skip;
+
+const RUN = Date.now().toString(36).slice(-5);
+
+describeLive('issues against a live instance', () => {
+ let team: TeamRead;
+ let statuses: StatusRead[];
+ const created: number[] = [];
+
+ beforeAll(async () => {
+ await setInstanceUrl(LIVE_URL!);
+ const token = await loginAuthLoginPost({
+ username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev',
+ password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123',
+ });
+ await persistToken(token.access_token);
+ team = (await listMyTeamsTeamsGet())[0];
+ statuses = await listStatusesTeamsTeamIdStatusesGet(team.id);
+ });
+
+ afterAll(async () => {
+ // These are real rows; do not leave them behind.
+ for (const id of created) {
+ await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined);
+ }
+ });
+
+ it('creates an issue that looks exactly like a web-created one', async () => {
+ const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Mobile create ${RUN}`,
+ });
+ created.push(issue.id);
+
+ // TEAMKEY-n, allocated by the server -- the client never invents one.
+ expect(issue.identifier).toBe(`${team.key}-${issue.number}`);
+ expect(issue.team_id).toBe(team.id);
+ expect(issue.creator).toHaveProperty('id');
+ // The defaults a title-only issue gets.
+ expect(issue.priority).toBe('no_priority');
+ expect(issue.estimate ?? null).toBeNull();
+ expect(issue.assignee ?? null).toBeNull();
+ // A status is assigned even though none was asked for.
+ expect(statuses.map((s) => s.id)).toContain(issue.status.id);
+ });
+
+ it('round-trips every property the form offers', async () => {
+ const label = await createLabelTeamsTeamIdLabelsPost(team.id, {
+ name: `mob-${RUN}`,
+ });
+ const project = await createProjectTeamsTeamIdProjectsPost(team.id, {
+ name: `Mobile ${RUN}`,
+ });
+ const status = statuses[1] ?? statuses[0];
+
+ const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Mobile full ${RUN}`,
+ description: '## Heading\n\n- [ ] a task',
+ status_id: status.id,
+ priority: 'high',
+ estimate: ESTIMATE_SCALE[3],
+ label_ids: [label.id],
+ project_id: project.id,
+ });
+ created.push(issue.id);
+
+ expect(issue.title).toBe(`Mobile full ${RUN}`);
+ expect(issue.description).toContain('## Heading');
+ expect(issue.status.id).toBe(status.id);
+ expect(issue.priority).toBe('high');
+ expect(issue.estimate).toBe(ESTIMATE_SCALE[3]);
+ expect(issue.project_id).toBe(project.id);
+ expect((issue.labels ?? []).map((l) => l.id)).toEqual([label.id]);
+
+ // And it reads back the same, which is what the detail screen renders.
+ const fetched = await getIssueIssuesIssueIdGet(issue.id);
+ expect(fetched.identifier).toBe(issue.identifier);
+ expect(fetched.estimate).toBe(ESTIMATE_SCALE[3]);
+ });
+
+ it('accepts the estimate scale the picker offers, and nothing else', async () => {
+ const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Mobile estimate ${RUN}`,
+ });
+ created.push(issue.id);
+
+ for (const points of ESTIMATE_SCALE) {
+ const updated = await updateIssueIssuesIssueIdPatch(issue.id, { estimate: points });
+ expect(updated.estimate).toBe(points);
+ }
+
+ // Off-scale values are refused, which is why the picker is a fixed list.
+ await expect(
+ updateIssueIssuesIssueIdPatch(issue.id, { estimate: 4 as never }),
+ ).rejects.toMatchObject({ response: { status: 422 } });
+ });
+
+ it('clears a property back to nothing', async () => {
+ // "Not sized" and "unassigned" are states, not the absence of an edit --
+ // the pickers offer them as rows and null is what expresses them.
+ const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Mobile clear ${RUN}`,
+ estimate: 5,
+ });
+ created.push(issue.id);
+ expect(issue.estimate).toBe(5);
+
+ const cleared = await updateIssueIssuesIssueIdPatch(issue.id, { estimate: null });
+ expect(cleared.estimate ?? null).toBeNull();
+ });
+
+ it('deletes an issue and stops listing it', async () => {
+ const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
+ title: `Mobile delete ${RUN}`,
+ });
+
+ await deleteIssueIssuesIssueIdDelete(issue.id);
+
+ await expect(getIssueIssuesIssueIdGet(issue.id)).rejects.toMatchObject({
+ response: { status: 404 },
+ });
+ const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 });
+ expect(page.items.map((i) => i.id)).not.toContain(issue.id);
+ });
+});
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index ea2a634..118f657 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -59,6 +59,10 @@ function RootNavigator() {
structurally impossible. */}
+ {/* Pushed over the tabs rather than being tab destinations: both are
+ things you enter from somewhere and come back from. */}
+
+
diff --git a/src/app/issue/[id].tsx b/src/app/issue/[id].tsx
new file mode 100644
index 0000000..9128aa0
--- /dev/null
+++ b/src/app/issue/[id].tsx
@@ -0,0 +1 @@
+export { IssueDetailScreen as default } from '@/issues/issue-detail-screen';
diff --git a/src/app/new-issue.tsx b/src/app/new-issue.tsx
new file mode 100644
index 0000000..f9c83b3
--- /dev/null
+++ b/src/app/new-issue.tsx
@@ -0,0 +1 @@
+export { NewIssueScreen as default } from '@/issues/new-issue-screen';
diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx
index d21b2b2..2b4f7ec 100644
--- a/src/board/board-screen.tsx
+++ b/src/board/board-screen.tsx
@@ -15,6 +15,7 @@ import { useTeamData } from '@/board/use-team-data';
import { NewTeamSheet } from '@/team/new-team-sheet';
import { useTeams } from '@/team/team-context';
import { TeamSwitcher } from '@/team/team-switcher';
+import { href } from '@/ui/href';
import { Icon } from '@/ui/icon';
import { useIsMultiPane } from '@/ui/layout';
import { AppText, Button, Loading, TeamBadge } from '@/ui/primitives';
@@ -155,10 +156,14 @@ export function BoardScreen() {
issues={issues}
estimates={data.estimates}
onMove={onMove}
- onCardPress={setMoving}
+ onCardPress={(issue) => router.push(href(`/issue/${issue.id}`))}
/>
) : (
-
+ router.push(href(`/issue/${issue.id}`))}
+ onMovePress={setMoving}
+ />
)}
+ router.push('/new-issue')}
+ style={{
+ position: 'absolute',
+ right: 20,
+ bottom: 24,
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: t.brand[600],
+ shadowColor: '#000',
+ shadowOpacity: 0.25,
+ shadowRadius: 12,
+ shadowOffset: { width: 0, height: 6 },
+ elevation: 6,
+ }}
+ >
+
+
+
setSwitching(false)}
diff --git a/src/board/issue-list.tsx b/src/board/issue-list.tsx
index 2c4568e..66bda84 100644
--- a/src/board/issue-list.tsx
+++ b/src/board/issue-list.tsx
@@ -14,9 +14,11 @@ import { useTokens } from '@/ui/theme';
*/
export function IssueList({
issues,
+ onPress,
onMovePress,
}: {
issues: IssueRead[];
+ onPress: (issue: IssueRead) => void;
onMovePress: (issue: IssueRead) => void;
}) {
const t = useTokens();
@@ -35,6 +37,7 @@ export function IssueList({
onPress(item)}
onLongPress={() => onMovePress(item)}
style={{
flexDirection: 'row',
diff --git a/src/issues/__tests__/use-issue-properties.test.ts b/src/issues/__tests__/use-issue-properties.test.ts
new file mode 100644
index 0000000..4d2d7d6
--- /dev/null
+++ b/src/issues/__tests__/use-issue-properties.test.ts
@@ -0,0 +1,31 @@
+import { labelFor } from '@/issues/use-issue-properties';
+
+const OPTIONS = [
+ { value: 1, label: 'Todo', color: '#abc' },
+ { value: 2, label: 'Done', color: '#def' },
+];
+
+describe('labelFor', () => {
+ it('names the chosen value and carries its colour', () => {
+ expect(labelFor(OPTIONS, 1, 'None')).toEqual({
+ text: 'Todo',
+ color: '#abc',
+ muted: false,
+ });
+ });
+
+ it('shows the empty label when nothing is chosen', () => {
+ // "Unassigned" and "Not sized" are real states the API distinguishes, so
+ // they read as values rather than as a blank row.
+ expect(labelFor(OPTIONS, null, 'Unassigned')).toEqual({
+ text: 'Unassigned',
+ muted: true,
+ });
+ });
+
+ it('still renders a value whose row the team has deleted', () => {
+ // An issue can outlive the label or project it points at; the row has to
+ // draw something rather than crash or vanish.
+ expect(labelFor(OPTIONS, 99, 'None')).toEqual({ text: '99', muted: true });
+ });
+});
diff --git a/src/issues/issue-detail-screen.tsx b/src/issues/issue-detail-screen.tsx
new file mode 100644
index 0000000..7567228
--- /dev/null
+++ b/src/issues/issue-detail-screen.tsx
@@ -0,0 +1,386 @@
+import { useState } from 'react';
+import { Alert as RNAlert, Pressable, ScrollView, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { router, useLocalSearchParams } from 'expo-router';
+import { useQueryClient } from '@tanstack/react-query';
+
+import {
+ deleteIssueIssuesIssueIdDelete,
+ updateIssueIssuesIssueIdPatch,
+ useGetIssueIssuesIssueIdGet,
+} from '@/api/generated/endpoints/issues/issues';
+import type { IssuePriority, IssueUpdate } from '@/api/generated/models';
+import { errorDetail } from '@/api/errors';
+import {
+ InlineCreate,
+ MultiPicker,
+ PropertyRow,
+ SinglePicker,
+} from '@/issues/property-picker';
+import { labelFor, useIssueProperties } from '@/issues/use-issue-properties';
+import { useTeams } from '@/team/team-context';
+import { Icon } from '@/ui/icon';
+import { Alert, AppText, Button, Card, Field, Loading } from '@/ui/primitives';
+import { useTokens } from '@/ui/theme';
+
+/**
+ * One issue, per `docs/design/mobile/144-issue-detail.svg`.
+ *
+ * Properties are written through as they are picked rather than collected
+ * behind a Save button: each is a single-field PATCH, and a form that has to be
+ * submitted invites half-made edits to be lost when the screen is dismissed.
+ * Title and description are the exception -- they are typed, so they save on
+ * blur.
+ */
+type Picker =
+ | 'status'
+ | 'priority'
+ | 'estimate'
+ | 'assignee'
+ | 'labels'
+ | 'project'
+ | 'cycle'
+ | null;
+
+export function IssueDetailScreen() {
+ const t = useTokens();
+ const queryClient = useQueryClient();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const issueId = Number(id);
+ const { teams } = useTeams();
+
+ const issueQuery = useGetIssueIssuesIssueIdGet(issueId, {
+ query: { enabled: Number.isInteger(issueId) },
+ });
+ const issue = issueQuery.data;
+
+ // The issue names its own team, which is not necessarily the active one --
+ // a link can arrive from anywhere.
+ const team = teams.find((candidate) => candidate.id === issue?.team_id);
+ const props = useIssueProperties(team);
+
+ const [picker, setPicker] = useState(null);
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [title, setTitle] = useState(null);
+ const [description, setDescription] = useState(null);
+
+ async function patch(update: IssueUpdate) {
+ if (!issue) return;
+ setError(null);
+ setSaving(true);
+ try {
+ await updateIssueIssuesIssueIdPatch(issue.id, update);
+ await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] });
+ // The board groups by status and totals points, so both have to re-read.
+ void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/issues`] });
+ void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/estimates`] });
+ } catch (err) {
+ setError(errorDetail(err, 'Could not save that change.'));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function confirmDelete() {
+ if (!issue) return;
+ RNAlert.alert(
+ `Delete ${issue.identifier}?`,
+ 'This also deletes its attachments. It cannot be undone.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ await deleteIssueIssuesIssueIdDelete(issue.id);
+ await queryClient.invalidateQueries({
+ queryKey: [`/teams/${issue.team_id}/issues`],
+ });
+ router.back();
+ } catch (err) {
+ setError(errorDetail(err, 'Could not delete the issue.'));
+ }
+ },
+ },
+ ],
+ );
+ }
+
+ if (issueQuery.isPending) return ;
+
+ if (!issue) {
+ return (
+
+
+ Issue not found
+
+ It may have been deleted, or belong to a team you are not on.
+
+
+
+
+ );
+ }
+
+ const status = labelFor(props.statusOptions, issue.status.id, 'None');
+ const priorityMeta = labelFor(props.priorityOptions, issue.priority, 'No priority');
+ const points = labelFor(props.estimateOptions, issue.estimate ?? null, 'Not sized');
+ const assignee = labelFor(props.assigneeOptions, issue.assignee?.id ?? null, 'Unassigned');
+ const project = labelFor(props.projectOptions, issue.project_id ?? null, 'None');
+ const cycle = labelFor(props.cycleOptions, issue.cycle_id ?? null, 'None');
+ const labelIds = (issue.labels ?? []).map((label) => label.id);
+
+ return (
+
+
+ router.back()}
+ >
+ Back
+
+
+ {issue.identifier}
+
+ {saving ? (
+ Saving…
+ ) : (
+
+ )}
+
+
+
+ {error ? {error} : null}
+
+ {issue.blocked_by_count > 0 ? (
+
+
+
+ Blocked by {issue.blocked_by_count} issue
+ {issue.blocked_by_count === 1 ? '' : 's'}
+
+
+ ) : null}
+
+
+ {
+ if (title !== null && title.trim() && title.trim() !== issue.title) {
+ void patch({ title: title.trim() });
+ }
+ setTitle(null);
+ }}
+ />
+ {
+ if (description !== null && description !== (issue.description ?? '')) {
+ void patch({ description: description || null });
+ }
+ setDescription(null);
+ }}
+ />
+
+
+
+ setPicker('status')}
+ />
+ setPicker('priority')}
+ />
+ setPicker('estimate')}
+ />
+ setPicker('assignee')}
+ />
+ label.name).join(', ')
+ }
+ muted={labelIds.length === 0}
+ onPress={() => setPicker('labels')}
+ />
+ setPicker('project')}
+ />
+ setPicker('cycle')}
+ />
+
+
+
+
+ Opened by {issue.creator.full_name}
+
+ {issue.child_count > 0 ? (
+
+ {issue.completed_child_count} of {issue.child_count} sub-issues done
+
+ ) : null}
+
+
+
+
+
+ Delete issue
+
+
+
+
+ value !== null && void patch({ status_id: value })}
+ onClose={() => setPicker(null)}
+ />
+ void patch({ priority: (value ?? 'no_priority') as IssuePriority })}
+ onClose={() => setPicker(null)}
+ />
+ void patch({ estimate: value as never })}
+ onClose={() => setPicker(null)}
+ />
+ void patch({ assignee_id: value })}
+ onClose={() => setPicker(null)}
+ />
+
+ void patch({
+ label_ids: labelIds.includes(labelId)
+ ? labelIds.filter((x) => x !== labelId)
+ : [...labelIds, labelId],
+ })
+ }
+ onClose={() => setPicker(null)}
+ footer={
+ {
+ const newId = await props.createLabel(name);
+ if (newId !== null) void patch({ label_ids: [...labelIds, newId] });
+ }}
+ />
+ }
+ />
+ void patch({ project_id: value })}
+ onClose={() => setPicker(null)}
+ footer={
+ {
+ const newId = await props.createProject(name);
+ if (newId !== null) void patch({ project_id: newId });
+ }}
+ />
+ }
+ />
+ void patch({ cycle_id: value })}
+ onClose={() => setPicker(null)}
+ />
+
+ );
+}
diff --git a/src/issues/new-issue-screen.tsx b/src/issues/new-issue-screen.tsx
new file mode 100644
index 0000000..579efa7
--- /dev/null
+++ b/src/issues/new-issue-screen.tsx
@@ -0,0 +1,301 @@
+import { useState } from 'react';
+import { KeyboardAvoidingView, Platform, Pressable, ScrollView, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { router } from 'expo-router';
+import { useQueryClient } from '@tanstack/react-query';
+
+import { createIssueTeamsTeamIdIssuesPost } from '@/api/generated/endpoints/issues/issues';
+import type { IssuePriority } from '@/api/generated/models';
+import { errorDetail } from '@/api/errors';
+import {
+ InlineCreate,
+ MultiPicker,
+ PropertyRow,
+ SinglePicker,
+} from '@/issues/property-picker';
+import { labelFor, useIssueProperties } from '@/issues/use-issue-properties';
+import { useTeams } from '@/team/team-context';
+import { href } from '@/ui/href';
+import { Alert, AppText, Button, Card, Field } from '@/ui/primitives';
+import { useTokens } from '@/ui/theme';
+
+/**
+ * Create an issue, per `docs/design/mobile/143-new-issue.svg`.
+ *
+ * Every property the web's new-issue modal offers, as tappable rows rather than
+ * a column of `