Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitc53cf00unknown_key

feat: native mobile app — React Native/Expo app for iOS and Android

feat: native mobile app — React Native/Expo app for iOS and Android

Full Expo 51 app in mobile/ with 11 screens (Auth, Dashboard, RepoList,
RepoDetail, FileViewer, IssueList, IssueDetail, PullList, PullDetail,
Notifications, Settings), 9 reusable components, 5 React hooks, typed
API client hitting the real Gluecron REST API, PAT-based auth via
SecureStore, stack + bottom-tab navigation, and Gluecron's violet dark
theme throughout.

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 25b1ff7
37 files changed+43180c53cf004aa3a6ca34084da3b32f400eb6455c8e9
37 changed files+4318−0
Addedmobile/App.tsx+13−0View fileUnifiedSplit
1import React from 'react';
2import { StatusBar } from 'expo-status-bar';
3import { SafeAreaProvider } from 'react-native-safe-area-context';
4import { RootNavigator } from './src/navigation/RootNavigator';
5
6export default function App() {
7 return (
8 <SafeAreaProvider>
9 <StatusBar style="light" backgroundColor="#08090f" />
10 <RootNavigator />
11 </SafeAreaProvider>
12 );
13}
Addedmobile/app.json+23−0View fileUnifiedSplit
1{
2 "expo": {
3 "name": "Gluecron",
4 "slug": "gluecron",
5 "version": "1.0.0",
6 "orientation": "portrait",
7 "icon": "./assets/icon.png",
8 "userInterfaceStyle": "dark",
9 "splash": {
10 "backgroundColor": "#08090f"
11 },
12 "ios": {
13 "supportsTablet": true,
14 "bundleIdentifier": "com.gluecron.app"
15 },
16 "android": {
17 "adaptiveIcon": {
18 "backgroundColor": "#08090f"
19 },
20 "package": "com.gluecron.app"
21 }
22 }
23}
Addedmobile/babel.config.js+6−0View fileUnifiedSplit
1module.exports = function (api) {
2 api.cache(true);
3 return {
4 presets: ['babel-preset-expo'],
5 };
6};
Addedmobile/package.json+28−0View fileUnifiedSplit
1{
2 "name": "gluecron-mobile",
3 "version": "1.0.0",
4 "main": "expo/AppEntry.js",
5 "scripts": {
6 "start": "expo start",
7 "android": "expo start --android",
8 "ios": "expo start --ios"
9 },
10 "dependencies": {
11 "expo": "~51.0.0",
12 "expo-status-bar": "~1.12.1",
13 "expo-secure-store": "~13.0.2",
14 "@react-navigation/native": "^6.1.17",
15 "@react-navigation/native-stack": "^6.9.26",
16 "@react-navigation/bottom-tabs": "^6.5.20",
17 "react-native-screens": "~3.31.1",
18 "react-native-safe-area-context": "4.10.5",
19 "react": "18.2.0",
20 "react-native": "0.74.3"
21 },
22 "devDependencies": {
23 "@babel/core": "^7.24.0",
24 "@types/react": "~18.2.79",
25 "@types/react-native": "~0.73.0",
26 "typescript": "~5.3.3"
27 }
28}
Addedmobile/src/api/client.ts+306−0View fileUnifiedSplit
1export interface User {
2 id: string;
3 username: string;
4 email: string;
5 displayName: string | null;
6 bio: string | null;
7 avatarUrl: string | null;
8 createdAt: string;
9}
10
11export interface Repository {
12 id: string;
13 name: string;
14 description: string | null;
15 isPrivate: boolean;
16 starCount: number;
17 forkCount: number;
18 issueCount: number;
19 defaultBranch: string;
20 language: string | null;
21 updatedAt: string;
22 createdAt: string;
23 ownerId: string;
24}
25
26export interface Commit {
27 sha: string;
28 message: string;
29 author: {
30 name: string;
31 email: string;
32 date: string;
33 };
34 committer: {
35 name: string;
36 email: string;
37 date: string;
38 };
39}
40
41export interface TreeEntry {
42 name: string;
43 path: string;
44 type: 'blob' | 'tree';
45 sha: string;
46 size?: number;
47 mode: string;
48}
49
50export interface FileContent {
51 name: string;
52 path: string;
53 sha: string;
54 size: number;
55 content: string;
56 encoding: string;
57 type: string;
58}
59
60export interface Issue {
61 id: string;
62 number: number;
63 title: string;
64 body: string | null;
65 state: 'open' | 'closed';
66 authorId: string;
67 createdAt: string;
68 updatedAt: string;
69 closedAt: string | null;
70 commentCount?: number;
71 labels?: Label[];
72}
73
74export interface IssueComment {
75 id: string;
76 body: string;
77 authorId: string;
78 createdAt: string;
79 updatedAt: string;
80 author?: { username: string; displayName: string | null };
81}
82
83export interface Label {
84 id: string;
85 name: string;
86 color: string;
87 description: string | null;
88}
89
90export interface PullRequest {
91 id: string;
92 number: number;
93 title: string;
94 body: string | null;
95 state: 'open' | 'closed' | 'merged';
96 baseBranch: string;
97 headBranch: string;
98 authorId: string;
99 createdAt: string;
100 updatedAt: string;
101 mergedAt: string | null;
102 closedAt: string | null;
103}
104
105export interface PullComment {
106 id: string;
107 body: string;
108 authorId: string;
109 filePath: string | null;
110 line: number | null;
111 createdAt: string;
112 isAiReview: boolean;
113 author?: { username: string; displayName: string | null };
114}
115
116export interface ActivityEvent {
117 id: string;
118 type: string;
119 payload: Record<string, unknown>;
120 createdAt: string;
121 repositoryId: string;
122}
123
124export interface Branch {
125 name: string;
126 sha: string;
127 isDefault: boolean;
128}
129
130export interface LoginResponse {
131 user: { id: string; username: string; email: string };
132 token: string;
133}
134
135class ApiError extends Error {
136 constructor(
137 public status: number,
138 public statusText: string,
139 public body?: unknown,
140 ) {
141 super(`${status} ${statusText}`);
142 this.name = 'ApiError';
143 }
144}
145
146let _baseUrl = 'https://gluecron.com';
147
148export function setBaseUrl(url: string) {
149 _baseUrl = url.replace(/\/$/, '');
150}
151
152export function getBaseUrl() {
153 return _baseUrl;
154}
155
156class GluecronClient {
157 private token: string | null = null;
158
159 setToken(token: string) {
160 this.token = token;
161 }
162
163 clearToken() {
164 this.token = null;
165 }
166
167 getToken() {
168 return this.token;
169 }
170
171 private async request<T>(path: string, options?: RequestInit): Promise<T> {
172 const url = `${_baseUrl}${path}`;
173 const headers: Record<string, string> = {
174 'Content-Type': 'application/json',
175 };
176 if (this.token) {
177 headers['Authorization'] = `Bearer ${this.token}`;
178 }
179 if (options?.headers) {
180 Object.assign(headers, options.headers);
181 }
182
183 const res = await fetch(url, {
184 ...options,
185 headers,
186 });
187
188 if (!res.ok) {
189 let body: unknown;
190 try {
191 body = await res.json();
192 } catch {
193 body = undefined;
194 }
195 throw new ApiError(res.status, res.statusText, body);
196 }
197
198 return res.json() as Promise<T>;
199 }
200
201 // ── Auth ────────────────────────────────────────────────────────────────────
202
203 async login(username: string, password: string): Promise<LoginResponse> {
204 return this.request<LoginResponse>('/api/auth/login', {
205 method: 'POST',
206 body: JSON.stringify({ username, password }),
207 });
208 }
209
210 async getMe(): Promise<User> {
211 return this.request<User>('/api/v2/user');
212 }
213
214 // ── Repos ───────────────────────────────────────────────────────────────────
215
216 async listUserRepos(username: string, sort = 'updated'): Promise<Repository[]> {
217 return this.request<Repository[]>(`/api/v2/users/${encodeURIComponent(username)}/repos?sort=${sort}`);
218 }
219
220 async getRepo(owner: string, repo: string): Promise<Repository> {
221 return this.request<Repository>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
222 }
223
224 async listBranches(owner: string, repo: string): Promise<Branch[]> {
225 return this.request<Branch[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`);
226 }
227
228 async listCommits(owner: string, repo: string, branch?: string, page = 1, limit = 30): Promise<Commit[]> {
229 const params = new URLSearchParams({ page: String(page), limit: String(limit) });
230 if (branch) params.set('branch', branch);
231 return this.request<Commit[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?${params}`);
232 }
233
234 async getFileTree(owner: string, repo: string, ref = 'HEAD', path?: string): Promise<TreeEntry[]> {
235 const params = new URLSearchParams();
236 if (path) params.set('path', path);
237 return this.request<TreeEntry[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(ref)}?${params}`);
238 }
239
240 async getFileContent(owner: string, repo: string, filePath: string, ref = 'HEAD'): Promise<FileContent> {
241 return this.request<FileContent>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
242 }
243
244 async getRepoActivity(owner: string, repo: string, limit = 30): Promise<ActivityEvent[]> {
245 return this.request<ActivityEvent[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/activity?limit=${limit}`);
246 }
247
248 // ── Issues ──────────────────────────────────────────────────────────────────
249
250 async listIssues(owner: string, repo: string, state: 'open' | 'closed' | 'all' = 'open', page = 1, limit = 30): Promise<Issue[]> {
251 const params = new URLSearchParams({ state, page: String(page), limit: String(limit) });
252 return this.request<Issue[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params}`);
253 }
254
255 async getIssue(owner: string, repo: string, number: number): Promise<{ issue: Issue; comments: IssueComment[] }> {
256 return this.request<{ issue: Issue; comments: IssueComment[] }>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`);
257 }
258
259 async createIssue(owner: string, repo: string, title: string, body: string): Promise<Issue> {
260 return this.request<Issue>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, {
261 method: 'POST',
262 body: JSON.stringify({ title, body }),
263 });
264 }
265
266 async createIssueComment(owner: string, repo: string, number: number, body: string): Promise<IssueComment> {
267 return this.request<IssueComment>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments`, {
268 method: 'POST',
269 body: JSON.stringify({ body }),
270 });
271 }
272
273 async updateIssueState(owner: string, repo: string, number: number, state: 'open' | 'closed'): Promise<Issue> {
274 return this.request<Issue>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`, {
275 method: 'PATCH',
276 body: JSON.stringify({ state }),
277 });
278 }
279
280 // ── Pull Requests ────────────────────────────────────────────────────────────
281
282 async listPulls(owner: string, repo: string, state: 'open' | 'closed' | 'merged' | 'all' = 'open', page = 1, limit = 30): Promise<PullRequest[]> {
283 const params = new URLSearchParams({ state, page: String(page), limit: String(limit) });
284 return this.request<PullRequest[]>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${params}`);
285 }
286
287 async getPull(owner: string, repo: string, number: number): Promise<{ pull: PullRequest; comments: PullComment[] }> {
288 return this.request<{ pull: PullRequest; comments: PullComment[] }>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`);
289 }
290
291 async createPull(owner: string, repo: string, title: string, body: string, head: string, base: string): Promise<PullRequest> {
292 return this.request<PullRequest>(`/api/v2/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, {
293 method: 'POST',
294 body: JSON.stringify({ title, body, head, base }),
295 });
296 }
297
298 // ── User repos shorthand ────────────────────────────────────────────────────
299
300 async listMyRepos(username: string): Promise<Repository[]> {
301 return this.listUserRepos(username, 'updated');
302 }
303}
304
305export const api = new GluecronClient();
306export { ApiError };
Addedmobile/src/components/CommitRow.tsx+76−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights, fonts } from '../theme/typography';
5import { type Commit } from '../api/client';
6
7interface Props {
8 commit: Commit;
9}
10
11function timeAgo(dateStr: string): string {
12 const diff = Date.now() - new Date(dateStr).getTime();
13 const minutes = Math.floor(diff / 60000);
14 if (minutes < 60) return `${minutes}m ago`;
15 const hours = Math.floor(minutes / 60);
16 if (hours < 24) return `${hours}h ago`;
17 const days = Math.floor(hours / 24);
18 if (days < 30) return `${days}d ago`;
19 return `${Math.floor(days / 30)}mo ago`;
20}
21
22export function CommitRow({ commit }: Props) {
23 const shortSha = commit.sha.slice(0, 7);
24 const subject = commit.message.split('\n')[0];
25
26 return (
27 <View style={styles.row}>
28 <View style={styles.content}>
29 <Text style={styles.message} numberOfLines={2}>
30 {subject}
31 </Text>
32 <View style={styles.meta}>
33 <Text style={styles.sha}>{shortSha}</Text>
34 <Text style={styles.author}>{commit.author.name}</Text>
35 <Text style={styles.time}>{timeAgo(commit.author.date)}</Text>
36 </View>
37 </View>
38 </View>
39 );
40}
41
42const styles = StyleSheet.create({
43 row: {
44 padding: 12,
45 borderBottomWidth: 1,
46 borderBottomColor: colors.border,
47 },
48 content: {
49 gap: 4,
50 },
51 message: {
52 color: colors.text,
53 fontSize: fontSizes.sm,
54 fontWeight: fontWeights.regular,
55 lineHeight: 18,
56 },
57 meta: {
58 flexDirection: 'row',
59 alignItems: 'center',
60 gap: 8,
61 },
62 sha: {
63 color: colors.accent,
64 fontSize: fontSizes.xs,
65 fontFamily: fonts.mono,
66 fontWeight: fontWeights.medium,
67 },
68 author: {
69 color: colors.textMuted,
70 fontSize: fontSizes.xs,
71 },
72 time: {
73 color: colors.textMuted,
74 fontSize: fontSizes.xs,
75 },
76});
Addedmobile/src/components/DiffViewer.tsx+90−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, ScrollView, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fonts } from '../theme/typography';
5
6interface DiffLine {
7 type: 'add' | 'remove' | 'context' | 'header';
8 content: string;
9 lineNo?: number;
10}
11
12interface Props {
13 diff: string;
14}
15
16function parseDiff(diff: string): DiffLine[] {
17 const lines: DiffLine[] = [];
18 for (const raw of diff.split('\n')) {
19 if (raw.startsWith('@@')) {
20 lines.push({ type: 'header', content: raw });
21 } else if (raw.startsWith('+') && !raw.startsWith('+++')) {
22 lines.push({ type: 'add', content: raw.slice(1) });
23 } else if (raw.startsWith('-') && !raw.startsWith('---')) {
24 lines.push({ type: 'remove', content: raw.slice(1) });
25 } else {
26 lines.push({ type: 'context', content: raw.startsWith(' ') ? raw.slice(1) : raw });
27 }
28 }
29 return lines;
30}
31
32function lineStyle(type: DiffLine['type']) {
33 switch (type) {
34 case 'add': return { bg: 'rgba(52,211,153,0.10)', prefix: '+', color: colors.green };
35 case 'remove': return { bg: 'rgba(248,113,113,0.10)', prefix: '-', color: colors.red };
36 case 'header': return { bg: 'rgba(140,109,255,0.10)', prefix: '', color: colors.accent };
37 default: return { bg: 'transparent', prefix: ' ', color: colors.textMuted };
38 }
39}
40
41export function DiffViewer({ diff }: Props) {
42 const lines = parseDiff(diff);
43
44 return (
45 <ScrollView horizontal style={styles.scroll} showsHorizontalScrollIndicator={false}>
46 <View style={styles.container}>
47 {lines.map((line, i) => {
48 const s = lineStyle(line.type);
49 return (
50 <View key={i} style={[styles.line, { backgroundColor: s.bg }]}>
51 <Text style={[styles.prefix, { color: s.color }]}>{s.prefix}</Text>
52 <Text style={[styles.code, { color: s.color }]}>
53 {line.content || ' '}
54 </Text>
55 </View>
56 );
57 })}
58 </View>
59 </ScrollView>
60 );
61}
62
63const styles = StyleSheet.create({
64 scroll: {
65 flex: 1,
66 backgroundColor: colors.bgSecondary,
67 borderRadius: 8,
68 },
69 container: {
70 padding: 4,
71 minWidth: '100%',
72 },
73 line: {
74 flexDirection: 'row',
75 paddingHorizontal: 4,
76 paddingVertical: 1,
77 borderRadius: 2,
78 },
79 prefix: {
80 fontSize: fontSizes.xs,
81 fontFamily: fonts.mono,
82 width: 14,
83 textAlign: 'center',
84 },
85 code: {
86 fontSize: fontSizes.xs,
87 fontFamily: fonts.mono,
88 lineHeight: 18,
89 },
90});
Addedmobile/src/components/EmptyState.tsx+48−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes } from '../theme/typography';
5
6interface Props {
7 title: string;
8 subtitle?: string;
9 icon?: string;
10}
11
12export function EmptyState({ title, subtitle, icon = '~' }: Props) {
13 return (
14 <View style={styles.container}>
15 <Text style={styles.icon}>{icon}</Text>
16 <Text style={styles.title}>{title}</Text>
17 {subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
18 </View>
19 );
20}
21
22const styles = StyleSheet.create({
23 container: {
24 flex: 1,
25 alignItems: 'center',
26 justifyContent: 'center',
27 padding: 32,
28 backgroundColor: colors.bg,
29 },
30 icon: {
31 fontSize: 40,
32 color: colors.textMuted,
33 marginBottom: 12,
34 },
35 title: {
36 color: colors.text,
37 fontSize: fontSizes.md,
38 fontWeight: '600',
39 textAlign: 'center',
40 marginBottom: 8,
41 },
42 subtitle: {
43 color: colors.textMuted,
44 fontSize: fontSizes.sm,
45 textAlign: 'center',
46 lineHeight: 20,
47 },
48});
Addedmobile/src/components/ErrorState.tsx+57−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes } from '../theme/typography';
5
6interface Props {
7 message: string;
8 onRetry?: () => void;
9}
10
11export function ErrorState({ message, onRetry }: Props) {
12 return (
13 <View style={styles.container}>
14 <Text style={styles.icon}>!</Text>
15 <Text style={styles.message}>{message}</Text>
16 {onRetry && (
17 <TouchableOpacity style={styles.retryBtn} onPress={onRetry} activeOpacity={0.75}>
18 <Text style={styles.retryText}>Retry</Text>
19 </TouchableOpacity>
20 )}
21 </View>
22 );
23}
24
25const styles = StyleSheet.create({
26 container: {
27 flex: 1,
28 alignItems: 'center',
29 justifyContent: 'center',
30 padding: 32,
31 backgroundColor: colors.bg,
32 },
33 icon: {
34 fontSize: 40,
35 color: colors.red,
36 marginBottom: 12,
37 fontWeight: '700',
38 },
39 message: {
40 color: colors.textMuted,
41 fontSize: fontSizes.base,
42 textAlign: 'center',
43 lineHeight: 22,
44 marginBottom: 20,
45 },
46 retryBtn: {
47 backgroundColor: colors.accentDim,
48 borderRadius: 8,
49 paddingHorizontal: 20,
50 paddingVertical: 10,
51 },
52 retryText: {
53 color: colors.accent,
54 fontSize: fontSizes.base,
55 fontWeight: '600',
56 },
57});
Addedmobile/src/components/IssueRow.tsx+127−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type Issue } from '../api/client';
6
7interface Props {
8 issue: Issue;
9 onPress: () => void;
10}
11
12function timeAgo(dateStr: string): string {
13 const diff = Date.now() - new Date(dateStr).getTime();
14 const minutes = Math.floor(diff / 60000);
15 if (minutes < 60) return `${minutes}m ago`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return `${hours}h ago`;
18 const days = Math.floor(hours / 24);
19 if (days < 30) return `${days}d ago`;
20 return `${Math.floor(days / 30)}mo ago`;
21}
22
23export function IssueRow({ issue, onPress }: Props) {
24 const isOpen = issue.state === 'open';
25
26 return (
27 <TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.75}>
28 <View style={[styles.dot, { backgroundColor: isOpen ? colors.green : colors.textMuted }]} />
29
30 <View style={styles.content}>
31 <Text style={styles.title} numberOfLines={2}>
32 {issue.title}
33 </Text>
34
35 <View style={styles.meta}>
36 <Text style={styles.number}>#{issue.number}</Text>
37 <Text style={styles.time}>opened {timeAgo(issue.createdAt)}</Text>
38 {typeof issue.commentCount === 'number' && issue.commentCount > 0 && (
39 <View style={styles.commentBadge}>
40 <Text style={styles.commentText}>◯ {issue.commentCount}</Text>
41 </View>
42 )}
43 </View>
44
45 {issue.labels && issue.labels.length > 0 && (
46 <View style={styles.labels}>
47 {issue.labels.slice(0, 3).map((label) => (
48 <View
49 key={label.id}
50 style={[styles.label, { backgroundColor: `#${label.color}22`, borderColor: `#${label.color}` }]}
51 >
52 <Text style={[styles.labelText, { color: `#${label.color}` }]}>{label.name}</Text>
53 </View>
54 ))}
55 </View>
56 )}
57 </View>
58 </TouchableOpacity>
59 );
60}
61
62const styles = StyleSheet.create({
63 row: {
64 flexDirection: 'row',
65 alignItems: 'flex-start',
66 padding: 14,
67 borderBottomWidth: 1,
68 borderBottomColor: colors.border,
69 gap: 12,
70 },
71 dot: {
72 width: 8,
73 height: 8,
74 borderRadius: 4,
75 marginTop: 5,
76 flexShrink: 0,
77 },
78 content: {
79 flex: 1,
80 gap: 4,
81 },
82 title: {
83 color: colors.text,
84 fontSize: fontSizes.base,
85 fontWeight: fontWeights.medium,
86 lineHeight: 20,
87 },
88 meta: {
89 flexDirection: 'row',
90 alignItems: 'center',
91 gap: 8,
92 flexWrap: 'wrap',
93 },
94 number: {
95 color: colors.textMuted,
96 fontSize: fontSizes.xs,
97 },
98 time: {
99 color: colors.textMuted,
100 fontSize: fontSizes.xs,
101 },
102 commentBadge: {
103 flexDirection: 'row',
104 alignItems: 'center',
105 gap: 3,
106 },
107 commentText: {
108 color: colors.textMuted,
109 fontSize: fontSizes.xs,
110 },
111 labels: {
112 flexDirection: 'row',
113 flexWrap: 'wrap',
114 gap: 4,
115 marginTop: 4,
116 },
117 label: {
118 borderRadius: 10,
119 paddingHorizontal: 8,
120 paddingVertical: 2,
121 borderWidth: 1,
122 },
123 labelText: {
124 fontSize: fontSizes.xs,
125 fontWeight: fontWeights.medium,
126 },
127});
Addedmobile/src/components/LoadingSpinner.tsx+37−0View fileUnifiedSplit
1import React from 'react';
2import { ActivityIndicator, View, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4
5interface Props {
6 size?: 'small' | 'large';
7 fullScreen?: boolean;
8}
9
10export function LoadingSpinner({ size = 'large', fullScreen = false }: Props) {
11 if (fullScreen) {
12 return (
13 <View style={styles.fullScreen}>
14 <ActivityIndicator size={size} color={colors.accent} />
15 </View>
16 );
17 }
18 return (
19 <View style={styles.inline}>
20 <ActivityIndicator size={size} color={colors.accent} />
21 </View>
22 );
23}
24
25const styles = StyleSheet.create({
26 fullScreen: {
27 flex: 1,
28 alignItems: 'center',
29 justifyContent: 'center',
30 backgroundColor: colors.bg,
31 },
32 inline: {
33 padding: 24,
34 alignItems: 'center',
35 justifyContent: 'center',
36 },
37});
Addedmobile/src/components/NotifRow.tsx+134−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type Notification } from '../hooks/useNotifications';
6
7interface Props {
8 notification: Notification;
9 onPress?: () => void;
10}
11
12function timeAgo(dateStr: string): string {
13 const diff = Date.now() - new Date(dateStr).getTime();
14 const minutes = Math.floor(diff / 60000);
15 if (minutes < 60) return `${minutes}m ago`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return `${hours}h ago`;
18 const days = Math.floor(hours / 24);
19 return `${days}d ago`;
20}
21
22function typeIcon(type: string): string {
23 if (type.startsWith('issue')) return '●';
24 if (type.startsWith('pr')) return '⑂';
25 if (type === 'push') return '↑';
26 if (type === 'star') return '★';
27 return '◈';
28}
29
30function typeColor(type: string): string {
31 if (type.startsWith('issue')) return colors.green;
32 if (type.startsWith('pr')) return colors.accent;
33 if (type === 'push') return colors.blue;
34 if (type === 'star') return colors.yellow;
35 return colors.textMuted;
36}
37
38export function NotifRow({ notification, onPress }: Props) {
39 const icon = typeIcon(notification.type);
40 const iconColor = typeColor(notification.type);
41
42 return (
43 <TouchableOpacity style={[styles.row, !notification.read && styles.unread]} onPress={onPress} activeOpacity={0.75}>
44 <View style={styles.iconWrap}>
45 <Text style={[styles.icon, { color: iconColor }]}>{icon}</Text>
46 </View>
47
48 <View style={styles.content}>
49 <View style={styles.header}>
50 <Text style={styles.title} numberOfLines={1}>
51 {notification.title}
52 </Text>
53 {!notification.read && <View style={styles.unreadDot} />}
54 </View>
55 {notification.subtitle ? (
56 <Text style={styles.subtitle} numberOfLines={1}>{notification.subtitle}</Text>
57 ) : null}
58 <View style={styles.footer}>
59 {notification.repoOwner && notification.repoName && (
60 <Text style={styles.repo}>{notification.repoOwner}/{notification.repoName}</Text>
61 )}
62 <Text style={styles.time}>{timeAgo(notification.createdAt)}</Text>
63 </View>
64 </View>
65 </TouchableOpacity>
66 );
67}
68
69const styles = StyleSheet.create({
70 row: {
71 flexDirection: 'row',
72 alignItems: 'flex-start',
73 padding: 14,
74 borderBottomWidth: 1,
75 borderBottomColor: colors.border,
76 gap: 12,
77 },
78 unread: {
79 backgroundColor: colors.bgSecondary,
80 },
81 iconWrap: {
82 width: 28,
83 height: 28,
84 borderRadius: 14,
85 backgroundColor: colors.bgSurface,
86 alignItems: 'center',
87 justifyContent: 'center',
88 flexShrink: 0,
89 },
90 icon: {
91 fontSize: 14,
92 },
93 content: {
94 flex: 1,
95 gap: 3,
96 },
97 header: {
98 flexDirection: 'row',
99 alignItems: 'center',
100 gap: 6,
101 },
102 title: {
103 color: colors.text,
104 fontSize: fontSizes.base,
105 fontWeight: fontWeights.medium,
106 flex: 1,
107 },
108 unreadDot: {
109 width: 7,
110 height: 7,
111 borderRadius: 4,
112 backgroundColor: colors.accent,
113 flexShrink: 0,
114 },
115 subtitle: {
116 color: colors.textMuted,
117 fontSize: fontSizes.sm,
118 },
119 footer: {
120 flexDirection: 'row',
121 alignItems: 'center',
122 gap: 8,
123 marginTop: 2,
124 },
125 repo: {
126 color: colors.textMuted,
127 fontSize: fontSizes.xs,
128 flex: 1,
129 },
130 time: {
131 color: colors.textMuted,
132 fontSize: fontSizes.xs,
133 },
134});
Addedmobile/src/components/PullRow.tsx+122−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type PullRequest } from '../api/client';
6
7interface Props {
8 pull: PullRequest;
9 onPress: () => void;
10}
11
12function timeAgo(dateStr: string): string {
13 const diff = Date.now() - new Date(dateStr).getTime();
14 const minutes = Math.floor(diff / 60000);
15 if (minutes < 60) return `${minutes}m ago`;
16 const hours = Math.floor(minutes / 60);
17 if (hours < 24) return `${hours}h ago`;
18 const days = Math.floor(hours / 24);
19 if (days < 30) return `${days}d ago`;
20 return `${Math.floor(days / 30)}mo ago`;
21}
22
23function stateColor(state: string): string {
24 switch (state) {
25 case 'open': return colors.green;
26 case 'merged': return colors.accent;
27 case 'closed': return colors.red;
28 default: return colors.textMuted;
29 }
30}
31
32function stateLabel(state: string): string {
33 switch (state) {
34 case 'open': return 'Open';
35 case 'merged': return 'Merged';
36 case 'closed': return 'Closed';
37 default: return state;
38 }
39}
40
41export function PullRow({ pull, onPress }: Props) {
42 return (
43 <TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.75}>
44 <View style={[styles.stateDot, { backgroundColor: stateColor(pull.state) }]} />
45
46 <View style={styles.content}>
47 <Text style={styles.title} numberOfLines={2}>
48 {pull.title}
49 </Text>
50
51 <View style={styles.meta}>
52 <Text style={styles.number}>#{pull.number}</Text>
53 <View style={[styles.stateBadge, { backgroundColor: stateColor(pull.state) + '22', borderColor: stateColor(pull.state) }]}>
54 <Text style={[styles.stateText, { color: stateColor(pull.state) }]}>{stateLabel(pull.state)}</Text>
55 </View>
56 <Text style={styles.branches}>
57 {pull.headBranch} → {pull.baseBranch}
58 </Text>
59 </View>
60
61 <Text style={styles.time}>opened {timeAgo(pull.createdAt)}</Text>
62 </View>
63 </TouchableOpacity>
64 );
65}
66
67const styles = StyleSheet.create({
68 row: {
69 flexDirection: 'row',
70 alignItems: 'flex-start',
71 padding: 14,
72 borderBottomWidth: 1,
73 borderBottomColor: colors.border,
74 gap: 12,
75 },
76 stateDot: {
77 width: 8,
78 height: 8,
79 borderRadius: 4,
80 marginTop: 5,
81 flexShrink: 0,
82 },
83 content: {
84 flex: 1,
85 gap: 4,
86 },
87 title: {
88 color: colors.text,
89 fontSize: fontSizes.base,
90 fontWeight: fontWeights.medium,
91 lineHeight: 20,
92 },
93 meta: {
94 flexDirection: 'row',
95 alignItems: 'center',
96 gap: 8,
97 flexWrap: 'wrap',
98 },
99 number: {
100 color: colors.textMuted,
101 fontSize: fontSizes.xs,
102 },
103 stateBadge: {
104 borderRadius: 4,
105 paddingHorizontal: 6,
106 paddingVertical: 2,
107 borderWidth: 1,
108 },
109 stateText: {
110 fontSize: fontSizes.xs,
111 fontWeight: fontWeights.medium,
112 },
113 branches: {
114 color: colors.textMuted,
115 fontSize: fontSizes.xs,
116 fontFamily: 'monospace',
117 },
118 time: {
119 color: colors.textMuted,
120 fontSize: fontSizes.xs,
121 },
122});
Addedmobile/src/components/RepoCard.tsx+164−0View fileUnifiedSplit
1import React from 'react';
2import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3import { colors } from '../theme/colors';
4import { fontSizes, fontWeights } from '../theme/typography';
5import { type Repository } from '../api/client';
6
7interface Props {
8 repo: Repository;
9 ownerUsername?: string;
10 onPress: () => void;
11}
12
13const LANG_COLORS: Record<string, string> = {
14 TypeScript: '#3178c6',
15 JavaScript: '#f1e05a',
16 Python: '#3572A5',
17 Go: '#00ADD8',
18 Rust: '#dea584',
19 Ruby: '#701516',
20 Java: '#b07219',
21 'C++': '#f34b7d',
22 C: '#555555',
23 Swift: '#F05138',
24 Kotlin: '#A97BFF',
25 PHP: '#4F5D95',
26 'C#': '#239120',
27 Shell: '#89e051',
28};
29
30function timeAgo(dateStr: string): string {
31 const diff = Date.now() - new Date(dateStr).getTime();
32 const seconds = Math.floor(diff / 1000);
33 if (seconds < 60) return 'just now';
34 const minutes = Math.floor(seconds / 60);
35 if (minutes < 60) return `${minutes}m ago`;
36 const hours = Math.floor(minutes / 60);
37 if (hours < 24) return `${hours}h ago`;
38 const days = Math.floor(hours / 24);
39 if (days < 30) return `${days}d ago`;
40 const months = Math.floor(days / 30);
41 return `${months}mo ago`;
42}
43
44export function RepoCard({ repo, ownerUsername, onPress }: Props) {
45 const langColor = repo.language ? (LANG_COLORS[repo.language] ?? colors.textMuted) : colors.textMuted;
46
47 return (
48 <TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.75}>
49 <View style={styles.header}>
50 <Text style={styles.name} numberOfLines={1}>
51 {ownerUsername ? `${ownerUsername}/` : ''}
52 <Text style={styles.repoName}>{repo.name}</Text>
53 </Text>
54 {repo.isPrivate && (
55 <View style={styles.privateBadge}>
56 <Text style={styles.privateBadgeText}>private</Text>
57 </View>
58 )}
59 </View>
60
61 {repo.description ? (
62 <Text style={styles.desc} numberOfLines={2}>
63 {repo.description}
64 </Text>
65 ) : null}
66
67 <View style={styles.meta}>
68 {repo.language && (
69 <View style={styles.langRow}>
70 <View style={[styles.langDot, { backgroundColor: langColor }]} />
71 <Text style={styles.metaText}>{repo.language}</Text>
72 </View>
73 )}
74 <View style={styles.metaItem}>
75 <Text style={styles.metaIcon}></Text>
76 <Text style={styles.metaText}>{repo.starCount}</Text>
77 </View>
78 <View style={styles.metaItem}>
79 <Text style={styles.metaIcon}></Text>
80 <Text style={styles.metaText}>{repo.forkCount}</Text>
81 </View>
82 <Text style={styles.metaTime}>{timeAgo(repo.updatedAt)}</Text>
83 </View>
84 </TouchableOpacity>
85 );
86}
87
88const styles = StyleSheet.create({
89 card: {
90 backgroundColor: colors.bgSurface,
91 borderRadius: 10,
92 padding: 14,
93 marginBottom: 10,
94 borderWidth: 1,
95 borderColor: colors.border,
96 },
97 header: {
98 flexDirection: 'row',
99 alignItems: 'center',
100 marginBottom: 6,
101 gap: 8,
102 },
103 name: {
104 flex: 1,
105 fontSize: fontSizes.base,
106 color: colors.textMuted,
107 fontWeight: fontWeights.regular,
108 },
109 repoName: {
110 color: colors.accent,
111 fontWeight: fontWeights.semibold,
112 },
113 privateBadge: {
114 backgroundColor: colors.accentDim,
115 borderRadius: 4,
116 paddingHorizontal: 6,
117 paddingVertical: 2,
118 },
119 privateBadgeText: {
120 color: colors.accent,
121 fontSize: fontSizes.xs,
122 fontWeight: fontWeights.medium,
123 },
124 desc: {
125 color: colors.textMuted,
126 fontSize: fontSizes.sm,
127 lineHeight: 18,
128 marginBottom: 10,
129 },
130 meta: {
131 flexDirection: 'row',
132 alignItems: 'center',
133 gap: 12,
134 flexWrap: 'wrap',
135 },
136 langRow: {
137 flexDirection: 'row',
138 alignItems: 'center',
139 gap: 4,
140 },
141 langDot: {
142 width: 10,
143 height: 10,
144 borderRadius: 5,
145 },
146 metaItem: {
147 flexDirection: 'row',
148 alignItems: 'center',
149 gap: 3,
150 },
151 metaIcon: {
152 color: colors.textMuted,
153 fontSize: fontSizes.xs,
154 },
155 metaText: {
156 color: colors.textMuted,
157 fontSize: fontSizes.xs,
158 },
159 metaTime: {
160 color: colors.textMuted,
161 fontSize: fontSizes.xs,
162 marginLeft: 'auto',
163 },
164});
Addedmobile/src/hooks/useAuth.ts+101−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type User } from '../api/client';
3import {
4 saveToken,
5 loadToken,
6 clearAll,
7 saveUser,
8 loadUser,
9 saveHost,
10 loadHost,
11} from '../store/auth';
12import { setBaseUrl } from '../api/client';
13
14export interface AuthState {
15 user: User | null;
16 token: string | null;
17 loading: boolean;
18 error: string | null;
19}
20
21export function useAuth() {
22 const [state, setState] = useState<AuthState>({
23 user: null,
24 token: null,
25 loading: true,
26 error: null,
27 });
28
29 // Hydrate from secure storage on mount
30 useEffect(() => {
31 (async () => {
32 try {
33 const [token, user, host] = await Promise.all([
34 loadToken(),
35 loadUser(),
36 loadHost(),
37 ]);
38 if (host) {
39 setBaseUrl(host);
40 }
41 if (token && user) {
42 setState({ user, token, loading: false, error: null });
43 } else {
44 setState({ user: null, token: null, loading: false, error: null });
45 }
46 } catch {
47 setState({ user: null, token: null, loading: false, error: null });
48 }
49 })();
50 }, []);
51
52 const login = useCallback(async (usernameOrToken: string, passwordOrEmpty?: string, host?: string) => {
53 setState((s) => ({ ...s, loading: true, error: null }));
54 try {
55 if (host) {
56 setBaseUrl(host);
57 await saveHost(host);
58 }
59
60 let token: string;
61 let user: User;
62
63 // PAT token login: if passwordOrEmpty is empty, treat usernameOrToken as raw token
64 if (!passwordOrEmpty) {
65 token = usernameOrToken;
66 api.setToken(token);
67 user = await api.getMe();
68 } else {
69 // Username + password login
70 const res = await api.login(usernameOrToken, passwordOrEmpty);
71 token = res.token;
72 api.setToken(token);
73 user = await api.getMe();
74 }
75
76 await Promise.all([saveToken(token), saveUser(user)]);
77 setState({ user, token, loading: false, error: null });
78 } catch (err: unknown) {
79 const msg = err instanceof Error ? err.message : 'Login failed';
80 setState((s) => ({ ...s, loading: false, error: msg }));
81 throw err;
82 }
83 }, []);
84
85 const logout = useCallback(async () => {
86 await clearAll();
87 setState({ user: null, token: null, loading: false, error: null });
88 }, []);
89
90 const clearError = useCallback(() => {
91 setState((s) => ({ ...s, error: null }));
92 }, []);
93
94 return {
95 ...state,
96 isAuthenticated: !!state.token && !!state.user,
97 login,
98 logout,
99 clearError,
100 };
101}
Addedmobile/src/hooks/useIssues.ts+68−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type Issue, type IssueComment } from '../api/client';
3
4export function useIssues(owner: string | null, repo: string | null, state: 'open' | 'closed' | 'all' = 'open') {
5 const [issues, setIssues] = useState<Issue[]>([]);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState<string | null>(null);
8
9 const fetch = useCallback(async () => {
10 if (!owner || !repo) return;
11 setLoading(true);
12 setError(null);
13 try {
14 const data = await api.listIssues(owner, repo, state);
15 setIssues(data);
16 } catch (err) {
17 setError(err instanceof Error ? err.message : 'Failed to load issues');
18 } finally {
19 setLoading(false);
20 }
21 }, [owner, repo, state]);
22
23 useEffect(() => {
24 fetch();
25 }, [fetch]);
26
27 return { issues, loading, error, refresh: fetch };
28}
29
30export function useIssue(owner: string | null, repo: string | null, number: number | null) {
31 const [issue, setIssue] = useState<Issue | null>(null);
32 const [comments, setComments] = useState<IssueComment[]>([]);
33 const [loading, setLoading] = useState(false);
34 const [error, setError] = useState<string | null>(null);
35 const [submitting, setSubmitting] = useState(false);
36
37 const fetch = useCallback(async () => {
38 if (!owner || !repo || number === null) return;
39 setLoading(true);
40 setError(null);
41 try {
42 const data = await api.getIssue(owner, repo, number);
43 setIssue(data.issue);
44 setComments(data.comments);
45 } catch (err) {
46 setError(err instanceof Error ? err.message : 'Failed to load issue');
47 } finally {
48 setLoading(false);
49 }
50 }, [owner, repo, number]);
51
52 useEffect(() => {
53 fetch();
54 }, [fetch]);
55
56 const addComment = useCallback(async (body: string) => {
57 if (!owner || !repo || number === null) return;
58 setSubmitting(true);
59 try {
60 const comment = await api.createIssueComment(owner, repo, number, body);
61 setComments((prev) => [...prev, comment]);
62 } finally {
63 setSubmitting(false);
64 }
65 }, [owner, repo, number]);
66
67 return { issue, comments, loading, error, submitting, refresh: fetch, addComment };
68}
Addedmobile/src/hooks/useNotifications.ts+109−0View fileUnifiedSplit
1import { useState, useCallback } from 'react';
2import { api, type ActivityEvent } from '../api/client';
3
4export interface Notification {
5 id: string;
6 type: string;
7 title: string;
8 subtitle: string;
9 read: boolean;
10 createdAt: string;
11 repoOwner?: string;
12 repoName?: string;
13}
14
15function activityToNotification(event: ActivityEvent & { repoOwner?: string; repoName?: string }): Notification {
16 const type = event.type || 'activity';
17 const payload = event.payload as Record<string, unknown>;
18
19 let title = 'Activity';
20 let subtitle = '';
21
22 switch (type) {
23 case 'push':
24 title = 'Push';
25 subtitle = `${payload.ref ?? 'branch'} updated`;
26 break;
27 case 'issue.opened':
28 title = 'Issue opened';
29 subtitle = String(payload.title ?? '');
30 break;
31 case 'issue.closed':
32 title = 'Issue closed';
33 subtitle = String(payload.title ?? '');
34 break;
35 case 'pr.opened':
36 title = 'PR opened';
37 subtitle = String(payload.title ?? '');
38 break;
39 case 'pr.merged':
40 title = 'PR merged';
41 subtitle = String(payload.title ?? '');
42 break;
43 case 'star':
44 title = 'New star';
45 subtitle = String(payload.username ?? 'Someone') + ' starred the repo';
46 break;
47 default:
48 title = type.replace(/[._]/g, ' ');
49 subtitle = '';
50 }
51
52 return {
53 id: event.id,
54 type,
55 title,
56 subtitle,
57 read: false,
58 createdAt: event.createdAt,
59 repoOwner: event.repoOwner,
60 repoName: event.repoName,
61 };
62}
63
64// Notifications are synthesized from activity feeds across user repos.
65// The Gluecron REST API doesn't expose a dedicated /notifications endpoint,
66// so we aggregate from the repos the user cares about.
67export function useNotifications(repos: Array<{ owner: string; name: string }>) {
68 const [notifications, setNotifications] = useState<Notification[]>([]);
69 const [loading, setLoading] = useState(false);
70 const [error, setError] = useState<string | null>(null);
71
72 const fetch = useCallback(async () => {
73 if (repos.length === 0) return;
74 setLoading(true);
75 setError(null);
76 try {
77 const results = await Promise.allSettled(
78 repos.slice(0, 5).map((r) =>
79 api.getRepoActivity(r.owner, r.name, 10).then((events) =>
80 events.map((e) => activityToNotification({ ...e, repoOwner: r.owner, repoName: r.name }))
81 )
82 )
83 );
84
85 const allNotifs: Notification[] = [];
86 for (const r of results) {
87 if (r.status === 'fulfilled') {
88 allNotifs.push(...r.value);
89 }
90 }
91
92 // Sort newest first
93 allNotifs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
94 setNotifications(allNotifs.slice(0, 50));
95 } catch (err) {
96 setError(err instanceof Error ? err.message : 'Failed to load notifications');
97 } finally {
98 setLoading(false);
99 }
100 }, [repos]);
101
102 const markAllRead = useCallback(() => {
103 setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
104 }, []);
105
106 const unreadCount = notifications.filter((n) => !n.read).length;
107
108 return { notifications, loading, error, refresh: fetch, markAllRead, unreadCount };
109}
Addedmobile/src/hooks/usePulls.ts+56−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type PullRequest, type PullComment } from '../api/client';
3
4export function usePulls(owner: string | null, repo: string | null, state: 'open' | 'closed' | 'merged' | 'all' = 'open') {
5 const [pulls, setPulls] = useState<PullRequest[]>([]);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState<string | null>(null);
8
9 const fetch = useCallback(async () => {
10 if (!owner || !repo) return;
11 setLoading(true);
12 setError(null);
13 try {
14 const data = await api.listPulls(owner, repo, state);
15 setPulls(data);
16 } catch (err) {
17 setError(err instanceof Error ? err.message : 'Failed to load pull requests');
18 } finally {
19 setLoading(false);
20 }
21 }, [owner, repo, state]);
22
23 useEffect(() => {
24 fetch();
25 }, [fetch]);
26
27 return { pulls, loading, error, refresh: fetch };
28}
29
30export function usePull(owner: string | null, repo: string | null, number: number | null) {
31 const [pull, setPull] = useState<PullRequest | null>(null);
32 const [comments, setComments] = useState<PullComment[]>([]);
33 const [loading, setLoading] = useState(false);
34 const [error, setError] = useState<string | null>(null);
35
36 const fetch = useCallback(async () => {
37 if (!owner || !repo || number === null) return;
38 setLoading(true);
39 setError(null);
40 try {
41 const data = await api.getPull(owner, repo, number);
42 setPull(data.pull);
43 setComments(data.comments);
44 } catch (err) {
45 setError(err instanceof Error ? err.message : 'Failed to load pull request');
46 } finally {
47 setLoading(false);
48 }
49 }, [owner, repo, number]);
50
51 useEffect(() => {
52 fetch();
53 }, [fetch]);
54
55 return { pull, comments, loading, error, refresh: fetch };
56}
Addedmobile/src/hooks/useRepo.ts+140−0View fileUnifiedSplit
1import { useState, useEffect, useCallback } from 'react';
2import { api, type Repository, type Commit, type TreeEntry, type FileContent, type Branch } from '../api/client';
3
4export function useUserRepos(username: string | null) {
5 const [repos, setRepos] = useState<Repository[]>([]);
6 const [loading, setLoading] = useState(false);
7 const [error, setError] = useState<string | null>(null);
8
9 const fetch = useCallback(async () => {
10 if (!username) return;
11 setLoading(true);
12 setError(null);
13 try {
14 const data = await api.listUserRepos(username);
15 setRepos(data);
16 } catch (err) {
17 setError(err instanceof Error ? err.message : 'Failed to load repos');
18 } finally {
19 setLoading(false);
20 }
21 }, [username]);
22
23 useEffect(() => {
24 fetch();
25 }, [fetch]);
26
27 return { repos, loading, error, refresh: fetch };
28}
29
30export function useRepo(owner: string | null, repo: string | null) {
31 const [data, setData] = useState<Repository | null>(null);
32 const [loading, setLoading] = useState(false);
33 const [error, setError] = useState<string | null>(null);
34
35 const fetch = useCallback(async () => {
36 if (!owner || !repo) return;
37 setLoading(true);
38 setError(null);
39 try {
40 const result = await api.getRepo(owner, repo);
41 setData(result);
42 } catch (err) {
43 setError(err instanceof Error ? err.message : 'Failed to load repo');
44 } finally {
45 setLoading(false);
46 }
47 }, [owner, repo]);
48
49 useEffect(() => {
50 fetch();
51 }, [fetch]);
52
53 return { repo: data, loading, error, refresh: fetch };
54}
55
56export function useCommits(owner: string | null, repoName: string | null, branch?: string) {
57 const [commits, setCommits] = useState<Commit[]>([]);
58 const [loading, setLoading] = useState(false);
59 const [error, setError] = useState<string | null>(null);
60
61 const fetch = useCallback(async () => {
62 if (!owner || !repoName) return;
63 setLoading(true);
64 setError(null);
65 try {
66 const data = await api.listCommits(owner, repoName, branch);
67 setCommits(data);
68 } catch (err) {
69 setError(err instanceof Error ? err.message : 'Failed to load commits');
70 } finally {
71 setLoading(false);
72 }
73 }, [owner, repoName, branch]);
74
75 useEffect(() => {
76 fetch();
77 }, [fetch]);
78
79 return { commits, loading, error, refresh: fetch };
80}
81
82export function useFileTree(owner: string | null, repoName: string | null, ref: string, path?: string) {
83 const [tree, setTree] = useState<TreeEntry[]>([]);
84 const [loading, setLoading] = useState(false);
85 const [error, setError] = useState<string | null>(null);
86
87 const fetch = useCallback(async () => {
88 if (!owner || !repoName) return;
89 setLoading(true);
90 setError(null);
91 try {
92 const data = await api.getFileTree(owner, repoName, ref, path);
93 setTree(data);
94 } catch (err) {
95 setError(err instanceof Error ? err.message : 'Failed to load file tree');
96 } finally {
97 setLoading(false);
98 }
99 }, [owner, repoName, ref, path]);
100
101 useEffect(() => {
102 fetch();
103 }, [fetch]);
104
105 return { tree, loading, error, refresh: fetch };
106}
107
108export function useFileContent(owner: string, repoName: string, filePath: string, ref = 'HEAD') {
109 const [file, setFile] = useState<FileContent | null>(null);
110 const [loading, setLoading] = useState(false);
111 const [error, setError] = useState<string | null>(null);
112
113 useEffect(() => {
114 setLoading(true);
115 setError(null);
116 api.getFileContent(owner, repoName, filePath, ref)
117 .then(setFile)
118 .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load file'))
119 .finally(() => setLoading(false));
120 }, [owner, repoName, filePath, ref]);
121
122 return { file, loading, error };
123}
124
125export function useBranches(owner: string | null, repoName: string | null) {
126 const [branches, setBranches] = useState<Branch[]>([]);
127 const [loading, setLoading] = useState(false);
128 const [error, setError] = useState<string | null>(null);
129
130 useEffect(() => {
131 if (!owner || !repoName) return;
132 setLoading(true);
133 api.listBranches(owner, repoName)
134 .then(setBranches)
135 .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load branches'))
136 .finally(() => setLoading(false));
137 }, [owner, repoName]);
138
139 return { branches, loading, error };
140}
Addedmobile/src/navigation/MainTabNavigator.tsx+141−0View fileUnifiedSplit
1import React from 'react';
2import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
3import { createNativeStackNavigator } from '@react-navigation/native-stack';
4import { Text, View } from 'react-native';
5import { colors } from '../theme/colors';
6import { fontSizes } from '../theme/typography';
7
8import { DashboardScreen } from '../screens/DashboardScreen';
9import { RepoListScreen } from '../screens/RepoListScreen';
10import { RepoDetailScreen } from '../screens/RepoDetailScreen';
11import { FileViewerScreen } from '../screens/FileViewerScreen';
12import { IssueListScreen } from '../screens/IssueListScreen';
13import { IssueDetailScreen } from '../screens/IssueDetailScreen';
14import { PullListScreen } from '../screens/PullListScreen';
15import { PullDetailScreen } from '../screens/PullDetailScreen';
16import { NotificationsScreen } from '../screens/NotificationsScreen';
17import { SettingsScreen } from '../screens/SettingsScreen';
18
19// ─── Stack param types ───────────────────────────────────────────────────────
20
21export type MainStackParamList = {
22 Dashboard: undefined;
23 RepoList: undefined;
24 RepoDetail: { owner: string; repo: string };
25 FileViewer: { owner: string; repo: string; path: string; ref: string };
26 IssueList: { owner: string; repo: string };
27 IssueDetail: { owner: string; repo: string; number: number };
28 PullList: { owner: string; repo: string };
29 PullDetail: { owner: string; repo: string; number: number };
30};
31
32const Stack = createNativeStackNavigator<MainStackParamList>();
33const Tab = createBottomTabNavigator();
34
35// ─── Stack navigators (one per tab root) ────────────────────────────────────
36
37function DashboardStack() {
38 return (
39 <Stack.Navigator screenOptions={stackScreenOptions}>
40 <Stack.Screen name="Dashboard" component={DashboardScreen} options={{ title: 'Dashboard' }} />
41 <Stack.Screen name="RepoDetail" component={RepoDetailScreen} options={({ route }) => ({ title: route.params.repo })} />
42 <Stack.Screen name="FileViewer" component={FileViewerScreen} options={({ route }) => ({ title: route.params.path.split('/').pop() ?? 'File' })} />
43 <Stack.Screen name="IssueList" component={IssueListScreen} options={{ title: 'Issues' }} />
44 <Stack.Screen name="IssueDetail" component={IssueDetailScreen} options={({ route }) => ({ title: `#${route.params.number}` })} />
45 <Stack.Screen name="PullList" component={PullListScreen} options={{ title: 'Pull Requests' }} />
46 <Stack.Screen name="PullDetail" component={PullDetailScreen} options={({ route }) => ({ title: `PR #${route.params.number}` })} />
47 {/* Dummy screens required by type — never actually navigated to from this stack */}
48 <Stack.Screen name="RepoList" component={RepoListScreen} options={{ title: 'Repositories' }} />
49 </Stack.Navigator>
50 );
51}
52
53function RepoStack() {
54 return (
55 <Stack.Navigator screenOptions={stackScreenOptions}>
56 <Stack.Screen name="RepoList" component={RepoListScreen} options={{ title: 'Repositories' }} />
57 <Stack.Screen name="RepoDetail" component={RepoDetailScreen} options={({ route }) => ({ title: route.params.repo })} />
58 <Stack.Screen name="FileViewer" component={FileViewerScreen} options={({ route }) => ({ title: route.params.path.split('/').pop() ?? 'File' })} />
59 <Stack.Screen name="IssueList" component={IssueListScreen} options={{ title: 'Issues' }} />
60 <Stack.Screen name="IssueDetail" component={IssueDetailScreen} options={({ route }) => ({ title: `#${route.params.number}` })} />
61 <Stack.Screen name="PullList" component={PullListScreen} options={{ title: 'Pull Requests' }} />
62 <Stack.Screen name="PullDetail" component={PullDetailScreen} options={({ route }) => ({ title: `PR #${route.params.number}` })} />
63 {/* Dummy — needed to satisfy shared type */}
64 <Stack.Screen name="Dashboard" component={DashboardScreen} options={{ title: 'Dashboard' }} />
65 </Stack.Navigator>
66 );
67}
68
69// ─── Tab navigator ───────────────────────────────────────────────────────────
70
71export function MainTabNavigator() {
72 return (
73 <Tab.Navigator
74 screenOptions={{
75 headerShown: false,
76 tabBarStyle: {
77 backgroundColor: colors.bgSecondary,
78 borderTopColor: colors.border,
79 borderTopWidth: 1,
80 height: 60,
81 paddingBottom: 8,
82 },
83 tabBarActiveTintColor: colors.accent,
84 tabBarInactiveTintColor: colors.textMuted,
85 tabBarLabelStyle: {
86 fontSize: fontSizes.xs,
87 fontWeight: '500',
88 },
89 }}
90 >
91 <Tab.Screen
92 name="DashboardTab"
93 component={DashboardStack}
94 options={{
95 title: 'Dashboard',
96 tabBarIcon: ({ color }) => <TabIcon icon="⌂" color={color} />,
97 }}
98 />
99 <Tab.Screen
100 name="ReposTab"
101 component={RepoStack}
102 options={{
103 title: 'Repos',
104 tabBarIcon: ({ color }) => <TabIcon icon="⌥" color={color} />,
105 }}
106 />
107 <Tab.Screen
108 name="NotificationsTab"
109 component={NotificationsScreen}
110 options={{
111 title: 'Notifications',
112 tabBarIcon: ({ color }) => <TabIcon icon="◉" color={color} />,
113 }}
114 />
115 <Tab.Screen
116 name="SettingsTab"
117 component={SettingsScreen}
118 options={{
119 title: 'Settings',
120 tabBarIcon: ({ color }) => <TabIcon icon="⚙" color={color} />,
121 }}
122 />
123 </Tab.Navigator>
124 );
125}
126
127// ─── Helpers ─────────────────────────────────────────────────────────────────
128
129function TabIcon({ icon, color }: { icon: string; color: string }) {
130 return (
131 <Text style={{ fontSize: 18, color, lineHeight: 22 }}>{icon}</Text>
132 );
133}
134
135const stackScreenOptions = {
136 headerStyle: { backgroundColor: colors.bgSecondary },
137 headerTintColor: colors.text,
138 headerTitleStyle: { color: colors.text, fontWeight: '600' as const },
139 headerBackTitleVisible: false,
140 contentStyle: { backgroundColor: colors.bg },
141};
Addedmobile/src/navigation/RootNavigator.tsx+81−0View fileUnifiedSplit
1import React, { createContext, useState, useEffect } from 'react';
2import { NavigationContainer } from '@react-navigation/native';
3import { createNativeStackNavigator } from '@react-navigation/native-stack';
4import { View, ActivityIndicator } from 'react-native';
5import { colors } from '../theme/colors';
6import { AuthScreen } from '../screens/AuthScreen';
7import { MainTabNavigator } from './MainTabNavigator';
8import { useAuth } from '../hooks/useAuth';
9import { type User } from '../api/client';
10
11// ─── Auth context — shared across the whole app ───────────────────────────────
12
13export interface AuthContextValue {
14 user: User | null;
15 token: string | null;
16 login: (tokenOrUsername: string, password?: string, host?: string) => Promise<void>;
17 logout: () => Promise<void>;
18}
19
20export const AuthContext = createContext<AuthContextValue>({
21 user: null,
22 token: null,
23 login: async () => {},
24 logout: async () => {},
25});
26
27// ─── Root param list ──────────────────────────────────────────────────────────
28
29type RootStackParamList = {
30 Auth: undefined;
31 Main: undefined;
32};
33
34const Stack = createNativeStackNavigator<RootStackParamList>();
35
36// ─── Navigator ────────────────────────────────────────────────────────────────
37
38export function RootNavigator() {
39 const auth = useAuth();
40
41 const contextValue: AuthContextValue = {
42 user: auth.user,
43 token: auth.token,
44 login: auth.login,
45 logout: auth.logout,
46 };
47
48 if (auth.loading) {
49 return (
50 <View style={{ flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }}>
51 <ActivityIndicator size="large" color={colors.accent} />
52 </View>
53 );
54 }
55
56 return (
57 <AuthContext.Provider value={contextValue}>
58 <NavigationContainer
59 theme={{
60 dark: true,
61 colors: {
62 primary: colors.accent,
63 background: colors.bg,
64 card: colors.bgSecondary,
65 text: colors.text,
66 border: colors.border,
67 notification: colors.accent,
68 },
69 }}
70 >
71 <Stack.Navigator screenOptions={{ headerShown: false }}>
72 {auth.isAuthenticated ? (
73 <Stack.Screen name="Main" component={MainTabNavigator} />
74 ) : (
75 <Stack.Screen name="Auth" component={AuthScreen} />
76 )}
77 </Stack.Navigator>
78 </NavigationContainer>
79 </AuthContext.Provider>
80 );
81}
Addedmobile/src/screens/AuthScreen.tsx+244−0View fileUnifiedSplit
1import React, { useState, useContext } from 'react';
2import {
3 View,
4 Text,
5 TextInput,
6 TouchableOpacity,
7 StyleSheet,
8 ScrollView,
9 KeyboardAvoidingView,
10 Platform,
11 ActivityIndicator,
12 Alert,
13} from 'react-native';
14import { SafeAreaView } from 'react-native-safe-area-context';
15import { colors } from '../theme/colors';
16import { fontSizes, fontWeights } from '../theme/typography';
17import { AuthContext } from '../navigation/RootNavigator';
18
19export function AuthScreen() {
20 const { login } = useContext(AuthContext);
21 const [token, setToken] = useState('');
22 const [host, setHost] = useState('https://gluecron.com');
23 const [showAdvanced, setShowAdvanced] = useState(false);
24 const [loading, setLoading] = useState(false);
25
26 async function handleLogin() {
27 const trimmed = token.trim();
28 if (!trimmed) {
29 Alert.alert('Error', 'Please enter your Personal Access Token');
30 return;
31 }
32 setLoading(true);
33 try {
34 await login(trimmed, undefined, host.trim() || 'https://gluecron.com');
35 } catch (err) {
36 Alert.alert('Login failed', err instanceof Error ? err.message : 'Invalid token');
37 } finally {
38 setLoading(false);
39 }
40 }
41
42 return (
43 <SafeAreaView style={styles.safe}>
44 <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.kav}>
45 <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
46 {/* Logo */}
47 <View style={styles.logoWrap}>
48 <Text style={styles.logoIcon}></Text>
49 <Text style={styles.logoText}>
50 glue<Text style={styles.logoAccent}>cron</Text>
51 </Text>
52 <Text style={styles.tagline}>AI-native git hosting</Text>
53 </View>
54
55 {/* Card */}
56 <View style={styles.card}>
57 <Text style={styles.heading}>Sign in</Text>
58 <Text style={styles.subheading}>
59 Use a Personal Access Token from your Gluecron account settings.
60 </Text>
61
62 <View style={styles.field}>
63 <Text style={styles.label}>Personal Access Token</Text>
64 <TextInput
65 style={styles.input}
66 value={token}
67 onChangeText={setToken}
68 placeholder="glc_..."
69 placeholderTextColor={colors.textMuted}
70 secureTextEntry
71 autoCapitalize="none"
72 autoCorrect={false}
73 autoComplete="password"
74 returnKeyType="done"
75 onSubmitEditing={handleLogin}
76 />
77 </View>
78
79 <TouchableOpacity
80 style={styles.advancedToggle}
81 onPress={() => setShowAdvanced((v) => !v)}
82 activeOpacity={0.7}
83 >
84 <Text style={styles.advancedToggleText}>
85 {showAdvanced ? '− ' : '+ '}Advanced (self-hosted)
86 </Text>
87 </TouchableOpacity>
88
89 {showAdvanced && (
90 <View style={styles.field}>
91 <Text style={styles.label}>Gluecron Host URL</Text>
92 <TextInput
93 style={styles.input}
94 value={host}
95 onChangeText={setHost}
96 placeholder="https://gluecron.com"
97 placeholderTextColor={colors.textMuted}
98 autoCapitalize="none"
99 autoCorrect={false}
100 keyboardType="url"
101 />
102 </View>
103 )}
104
105 <TouchableOpacity
106 style={[styles.loginBtn, loading && styles.loginBtnDisabled]}
107 onPress={handleLogin}
108 disabled={loading}
109 activeOpacity={0.8}
110 >
111 {loading ? (
112 <ActivityIndicator color={colors.white} size="small" />
113 ) : (
114 <Text style={styles.loginBtnText}>Sign in</Text>
115 )}
116 </TouchableOpacity>
117
118 <Text style={styles.hint}>
119 Generate a token at{' '}
120 <Text style={styles.hintLink}>Settings → Tokens</Text>
121 {' '}on your Gluecron instance.
122 </Text>
123 </View>
124 </ScrollView>
125 </KeyboardAvoidingView>
126 </SafeAreaView>
127 );
128}
129
130const styles = StyleSheet.create({
131 safe: {
132 flex: 1,
133 backgroundColor: colors.bg,
134 },
135 kav: {
136 flex: 1,
137 },
138 scroll: {
139 flexGrow: 1,
140 alignItems: 'center',
141 justifyContent: 'center',
142 padding: 24,
143 },
144 logoWrap: {
145 alignItems: 'center',
146 marginBottom: 36,
147 },
148 logoIcon: {
149 fontSize: 48,
150 color: colors.accent,
151 marginBottom: 8,
152 },
153 logoText: {
154 fontSize: fontSizes.xxl,
155 fontWeight: fontWeights.bold,
156 color: colors.text,
157 letterSpacing: -0.5,
158 },
159 logoAccent: {
160 color: colors.accent,
161 },
162 tagline: {
163 color: colors.textMuted,
164 fontSize: fontSizes.sm,
165 marginTop: 4,
166 },
167 card: {
168 width: '100%',
169 maxWidth: 400,
170 backgroundColor: colors.bgSurface,
171 borderRadius: 14,
172 padding: 24,
173 borderWidth: 1,
174 borderColor: colors.border,
175 },
176 heading: {
177 color: colors.text,
178 fontSize: fontSizes.xl,
179 fontWeight: fontWeights.bold,
180 marginBottom: 6,
181 },
182 subheading: {
183 color: colors.textMuted,
184 fontSize: fontSizes.sm,
185 lineHeight: 18,
186 marginBottom: 22,
187 },
188 field: {
189 marginBottom: 16,
190 },
191 label: {
192 color: colors.textMuted,
193 fontSize: fontSizes.xs,
194 fontWeight: fontWeights.medium,
195 marginBottom: 6,
196 textTransform: 'uppercase',
197 letterSpacing: 0.5,
198 },
199 input: {
200 backgroundColor: colors.bgSecondary,
201 borderWidth: 1,
202 borderColor: colors.border,
203 borderRadius: 8,
204 paddingHorizontal: 14,
205 paddingVertical: 12,
206 color: colors.text,
207 fontSize: fontSizes.base,
208 minHeight: 44,
209 },
210 advancedToggle: {
211 marginBottom: 12,
212 },
213 advancedToggleText: {
214 color: colors.accent,
215 fontSize: fontSizes.sm,
216 },
217 loginBtn: {
218 backgroundColor: colors.accent,
219 borderRadius: 8,
220 paddingVertical: 14,
221 alignItems: 'center',
222 marginTop: 8,
223 minHeight: 48,
224 justifyContent: 'center',
225 },
226 loginBtnDisabled: {
227 opacity: 0.6,
228 },
229 loginBtnText: {
230 color: colors.white,
231 fontSize: fontSizes.base,
232 fontWeight: fontWeights.semibold,
233 },
234 hint: {
235 color: colors.textMuted,
236 fontSize: fontSizes.xs,
237 textAlign: 'center',
238 marginTop: 16,
239 lineHeight: 18,
240 },
241 hintLink: {
242 color: colors.accent,
243 },
244});
Addedmobile/src/screens/DashboardScreen.tsx+207−0View fileUnifiedSplit
1import React, { useContext, useEffect, useState } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7 TouchableOpacity,
8 RefreshControl,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { colors } from '../theme/colors';
13import { fontSizes, fontWeights } from '../theme/typography';
14import { AuthContext } from '../navigation/RootNavigator';
15import { useUserRepos } from '../hooks/useRepo';
16import { RepoCard } from '../components/RepoCard';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { type MainStackParamList } from '../navigation/MainTabNavigator';
19
20interface Props {
21 navigation: NativeStackNavigationProp<MainStackParamList>;
22}
23
24export function DashboardScreen({ navigation }: Props) {
25 const { user } = useContext(AuthContext);
26 const { repos, loading, error, refresh } = useUserRepos(user?.username ?? null);
27 const [refreshing, setRefreshing] = useState(false);
28
29 async function onRefresh() {
30 setRefreshing(true);
31 await refresh();
32 setRefreshing(false);
33 }
34
35 const recentRepos = repos.slice(0, 6);
36 const hour = new Date().getHours();
37 const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
38
39 return (
40 <SafeAreaView style={styles.safe} edges={['top']}>
41 <ScrollView
42 style={styles.scroll}
43 contentContainerStyle={styles.content}
44 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />}
45 >
46 {/* Header */}
47 <View style={styles.header}>
48 <View style={styles.logoRow}>
49 <Text style={styles.logoIcon}></Text>
50 <Text style={styles.logoName}>
51 glue<Text style={styles.logoAccent}>cron</Text>
52 </Text>
53 </View>
54 <Text style={styles.greeting}>
55 {greeting}, <Text style={styles.username}>{user?.displayName || user?.username}</Text>
56 </Text>
57 <Text style={styles.subGreeting}>Here's what's happening in your repos.</Text>
58 </View>
59
60 {/* Stats row */}
61 <View style={styles.statsRow}>
62 <View style={styles.statCard}>
63 <Text style={styles.statNum}>{repos.length}</Text>
64 <Text style={styles.statLabel}>Repos</Text>
65 </View>
66 <View style={styles.statCard}>
67 <Text style={styles.statNum}>{repos.reduce((acc, r) => acc + r.starCount, 0)}</Text>
68 <Text style={styles.statLabel}>Stars</Text>
69 </View>
70 <View style={styles.statCard}>
71 <Text style={styles.statNum}>{repos.reduce((acc, r) => acc + r.issueCount, 0)}</Text>
72 <Text style={styles.statLabel}>Issues</Text>
73 </View>
74 </View>
75
76 {/* Recent Repos */}
77 <View style={styles.section}>
78 <View style={styles.sectionHeader}>
79 <Text style={styles.sectionTitle}>Recent repositories</Text>
80 <TouchableOpacity onPress={() => navigation.navigate('RepoList')} activeOpacity={0.7}>
81 <Text style={styles.seeAll}>See all</Text>
82 </TouchableOpacity>
83 </View>
84
85 {loading && !refreshing ? (
86 <LoadingSpinner size="small" />
87 ) : recentRepos.length === 0 ? (
88 <View style={styles.emptyWrap}>
89 <Text style={styles.emptyText}>No repositories yet</Text>
90 </View>
91 ) : (
92 recentRepos.map((repo) => (
93 <RepoCard
94 key={repo.id}
95 repo={repo}
96 onPress={() =>
97 navigation.navigate('RepoDetail', {
98 owner: user?.username ?? '',
99 repo: repo.name,
100 })
101 }
102 />
103 ))
104 )}
105 </View>
106 </ScrollView>
107 </SafeAreaView>
108 );
109}
110
111const styles = StyleSheet.create({
112 safe: {
113 flex: 1,
114 backgroundColor: colors.bg,
115 },
116 scroll: {
117 flex: 1,
118 },
119 content: {
120 padding: 16,
121 paddingBottom: 32,
122 },
123 header: {
124 marginBottom: 20,
125 },
126 logoRow: {
127 flexDirection: 'row',
128 alignItems: 'center',
129 gap: 6,
130 marginBottom: 16,
131 },
132 logoIcon: {
133 fontSize: 22,
134 color: colors.accent,
135 },
136 logoName: {
137 fontSize: fontSizes.md,
138 fontWeight: fontWeights.bold,
139 color: colors.text,
140 },
141 logoAccent: {
142 color: colors.accent,
143 },
144 greeting: {
145 fontSize: fontSizes.xl,
146 fontWeight: fontWeights.bold,
147 color: colors.text,
148 marginBottom: 4,
149 },
150 username: {
151 color: colors.accent,
152 },
153 subGreeting: {
154 color: colors.textMuted,
155 fontSize: fontSizes.sm,
156 },
157 statsRow: {
158 flexDirection: 'row',
159 gap: 10,
160 marginBottom: 24,
161 },
162 statCard: {
163 flex: 1,
164 backgroundColor: colors.bgSurface,
165 borderRadius: 10,
166 padding: 14,
167 alignItems: 'center',
168 borderWidth: 1,
169 borderColor: colors.border,
170 },
171 statNum: {
172 color: colors.accent,
173 fontSize: fontSizes.xl,
174 fontWeight: fontWeights.bold,
175 },
176 statLabel: {
177 color: colors.textMuted,
178 fontSize: fontSizes.xs,
179 marginTop: 2,
180 },
181 section: {
182 marginBottom: 24,
183 },
184 sectionHeader: {
185 flexDirection: 'row',
186 justifyContent: 'space-between',
187 alignItems: 'center',
188 marginBottom: 12,
189 },
190 sectionTitle: {
191 color: colors.text,
192 fontSize: fontSizes.md,
193 fontWeight: fontWeights.semibold,
194 },
195 seeAll: {
196 color: colors.accent,
197 fontSize: fontSizes.sm,
198 },
199 emptyWrap: {
200 padding: 20,
201 alignItems: 'center',
202 },
203 emptyText: {
204 color: colors.textMuted,
205 fontSize: fontSizes.sm,
206 },
207});
Addedmobile/src/screens/FileViewerScreen.tsx+231−0View fileUnifiedSplit
1import React, { useMemo } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7} from 'react-native';
8import { SafeAreaView } from 'react-native-safe-area-context';
9import { type RouteProp } from '@react-navigation/native';
10import { colors } from '../theme/colors';
11import { fontSizes, fonts } from '../theme/typography';
12import { useFileContent } from '../hooks/useRepo';
13import { LoadingSpinner } from '../components/LoadingSpinner';
14import { ErrorState } from '../components/ErrorState';
15import { type MainStackParamList } from '../navigation/MainTabNavigator';
16
17type Props = {
18 route: RouteProp<MainStackParamList, 'FileViewer'>;
19};
20
21// Minimal keyword syntax highlighting via regex replacement.
22// Returns an array of {text, color} segments for a single line.
23type Segment = { text: string; color: string };
24
25const KEYWORDS: Record<string, RegExp> = {
26 keyword: /\b(const|let|var|function|class|if|else|for|while|return|import|export|default|from|async|await|try|catch|throw|new|typeof|instanceof|void|null|undefined|true|false|extends|implements|interface|type|enum|namespace|module|declare|abstract|public|private|protected|static|readonly|override|def|fn|pub|use|mod|struct|impl|match|where|self|super|trait|yield|in|of|do|switch|case|break|continue|pass|and|or|not|is|as|with|lambda|del|global|nonlocal|assert|finally|raise|except|elif)\b/g,
27 string: /(["'`])(?:\\.|(?!\1)[^\\])*\1/g,
28 comment: /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/)/g,
29 number: /\b(\d+\.?\d*)\b/g,
30};
31
32function tokenizeLine(line: string, lang: string): Segment[] {
33 // Skip highlighting for binary or very long lines
34 if (line.length > 500) return [{ text: line, color: colors.text }];
35
36 // Collect all token ranges
37 const ranges: Array<{ start: number; end: number; color: string }> = [];
38
39 function addRanges(regex: RegExp, color: string) {
40 regex.lastIndex = 0;
41 let m: RegExpExecArray | null;
42 while ((m = regex.exec(line)) !== null) {
43 ranges.push({ start: m.index, end: m.index + m[0].length, color });
44 }
45 }
46
47 addRanges(new RegExp(KEYWORDS.comment.source, 'g'), colors.textMuted);
48 addRanges(new RegExp(KEYWORDS.string.source, 'g'), colors.green);
49 addRanges(new RegExp(KEYWORDS.keyword.source, 'g'), colors.accent);
50 addRanges(new RegExp(KEYWORDS.number.source, 'g'), colors.yellow);
51
52 if (ranges.length === 0) return [{ text: line, color: colors.text }];
53
54 // Sort by start, resolve overlaps
55 ranges.sort((a, b) => a.start - b.start);
56
57 const segments: Segment[] = [];
58 let cursor = 0;
59 for (const r of ranges) {
60 if (r.start < cursor) continue; // overlapping — skip
61 if (r.start > cursor) {
62 segments.push({ text: line.slice(cursor, r.start), color: colors.text });
63 }
64 segments.push({ text: line.slice(r.start, r.end), color: r.color });
65 cursor = r.end;
66 }
67 if (cursor < line.length) {
68 segments.push({ text: line.slice(cursor), color: colors.text });
69 }
70
71 return segments;
72}
73
74function getExtension(path: string): string {
75 const parts = path.split('.');
76 return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
77}
78
79const CODE_EXTS = new Set([
80 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
81 'py', 'rb', 'go', 'rs', 'c', 'cpp', 'cc', 'h', 'hpp',
82 'java', 'kt', 'swift', 'cs', 'php', 'sh', 'bash', 'zsh',
83 'toml', 'yaml', 'yml', 'json', 'md', 'sql', 'graphql',
84 'css', 'scss', 'sass', 'html', 'xml', 'svelte', 'vue',
85]);
86
87export function FileViewerScreen({ route }: Props) {
88 const { owner, repo, path, ref } = route.params;
89 const { file, loading, error } = useFileContent(owner, repo, path, ref);
90
91 const ext = getExtension(path);
92 const isCode = CODE_EXTS.has(ext);
93
94 const content = useMemo(() => {
95 if (!file) return '';
96 if (file.encoding === 'base64') {
97 try {
98 return atob(file.content.replace(/\n/g, ''));
99 } catch {
100 return file.content;
101 }
102 }
103 return file.content;
104 }, [file]);
105
106 const lines = useMemo(() => content.split('\n'), [content]);
107
108 if (loading) return <LoadingSpinner fullScreen />;
109 if (error || !file) return <ErrorState message={error || 'File not found'} />;
110
111 const isBinary = !isCode && file.size > 0;
112
113 return (
114 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
115 {/* File info bar */}
116 <View style={styles.infoBar}>
117 <Text style={styles.fileName} numberOfLines={1}>{path.split('/').pop()}</Text>
118 <Text style={styles.fileMeta}>{formatSize(file.size)} · {lines.length} lines</Text>
119 </View>
120
121 {isBinary ? (
122 <View style={styles.binaryWrap}>
123 <Text style={styles.binaryText}>Binary file — preview not available</Text>
124 <Text style={styles.binarySize}>{formatSize(file.size)}</Text>
125 </View>
126 ) : (
127 <ScrollView horizontal showsHorizontalScrollIndicator style={styles.hScroll}>
128 <ScrollView style={styles.vScroll}>
129 <View style={styles.codeWrap}>
130 {lines.map((line, idx) => {
131 const segments = isCode ? tokenizeLine(line, ext) : [{ text: line, color: colors.text }];
132 return (
133 <View key={idx} style={styles.codeLine}>
134 <Text style={styles.lineNo}>{idx + 1}</Text>
135 <Text style={styles.lineContent}>
136 {segments.map((seg, si) => (
137 <Text key={si} style={{ color: seg.color }}>
138 {seg.text}
139 </Text>
140 ))}
141 </Text>
142 </View>
143 );
144 })}
145 </View>
146 </ScrollView>
147 </ScrollView>
148 )}
149 </SafeAreaView>
150 );
151}
152
153function formatSize(bytes: number): string {
154 if (bytes < 1024) return `${bytes} B`;
155 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
156 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
157}
158
159const styles = StyleSheet.create({
160 safe: {
161 flex: 1,
162 backgroundColor: colors.bg,
163 },
164 infoBar: {
165 flexDirection: 'row',
166 justifyContent: 'space-between',
167 alignItems: 'center',
168 paddingHorizontal: 16,
169 paddingVertical: 10,
170 borderBottomWidth: 1,
171 borderBottomColor: colors.border,
172 backgroundColor: colors.bgSecondary,
173 },
174 fileName: {
175 color: colors.text,
176 fontSize: fontSizes.sm,
177 fontFamily: fonts.mono,
178 fontWeight: '600',
179 flex: 1,
180 marginRight: 12,
181 },
182 fileMeta: {
183 color: colors.textMuted,
184 fontSize: fontSizes.xs,
185 },
186 hScroll: {
187 flex: 1,
188 },
189 vScroll: {
190 flex: 1,
191 },
192 codeWrap: {
193 padding: 8,
194 paddingBottom: 40,
195 },
196 codeLine: {
197 flexDirection: 'row',
198 minHeight: 20,
199 },
200 lineNo: {
201 width: 36,
202 color: colors.textMuted,
203 fontSize: fontSizes.xs,
204 fontFamily: fonts.mono,
205 textAlign: 'right',
206 paddingRight: 12,
207 lineHeight: 20,
208 userSelect: 'none',
209 },
210 lineContent: {
211 fontSize: fontSizes.xs,
212 fontFamily: fonts.mono,
213 lineHeight: 20,
214 color: colors.text,
215 },
216 binaryWrap: {
217 flex: 1,
218 alignItems: 'center',
219 justifyContent: 'center',
220 padding: 32,
221 },
222 binaryText: {
223 color: colors.textMuted,
224 fontSize: fontSizes.base,
225 marginBottom: 8,
226 },
227 binarySize: {
228 color: colors.textMuted,
229 fontSize: fontSizes.sm,
230 },
231});
Addedmobile/src/screens/IssueDetailScreen.tsx+257−0View fileUnifiedSplit
1import React, { useState, useRef } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7 TextInput,
8 TouchableOpacity,
9 ActivityIndicator,
10 KeyboardAvoidingView,
11 Platform,
12 Alert,
13} from 'react-native';
14import { SafeAreaView } from 'react-native-safe-area-context';
15import { type RouteProp } from '@react-navigation/native';
16import { colors } from '../theme/colors';
17import { fontSizes, fontWeights } from '../theme/typography';
18import { useIssue } from '../hooks/useIssues';
19import { LoadingSpinner } from '../components/LoadingSpinner';
20import { ErrorState } from '../components/ErrorState';
21import { type MainStackParamList } from '../navigation/MainTabNavigator';
22
23type Props = {
24 route: RouteProp<MainStackParamList, 'IssueDetail'>;
25};
26
27function renderBody(text: string | null) {
28 if (!text) return null;
29 // Simple markdown-ish rendering: bold, italic, code spans
30 const lines = text.split('\n');
31 return lines.map((line, i) => {
32 // Headings
33 if (line.startsWith('### ')) {
34 return <Text key={i} style={styles.h3}>{line.slice(4)}</Text>;
35 }
36 if (line.startsWith('## ')) {
37 return <Text key={i} style={styles.h2}>{line.slice(3)}</Text>;
38 }
39 if (line.startsWith('# ')) {
40 return <Text key={i} style={styles.h1}>{line.slice(2)}</Text>;
41 }
42 // Code block lines
43 if (line.startsWith('```') || line.startsWith(' ')) {
44 return <Text key={i} style={styles.codeLine}>{line}</Text>;
45 }
46 if (!line.trim()) {
47 return <Text key={i} style={styles.emptyLine}>{'\n'}</Text>;
48 }
49 return <Text key={i} style={styles.bodyLine}>{line}</Text>;
50 });
51}
52
53function timeAgo(dateStr: string): string {
54 const diff = Date.now() - new Date(dateStr).getTime();
55 const minutes = Math.floor(diff / 60000);
56 if (minutes < 60) return `${minutes}m ago`;
57 const hours = Math.floor(minutes / 60);
58 if (hours < 24) return `${hours}h ago`;
59 const days = Math.floor(hours / 24);
60 return `${days}d ago`;
61}
62
63export function IssueDetailScreen({ route }: Props) {
64 const { owner, repo, number } = route.params;
65 const { issue, comments, loading, error, submitting, refresh, addComment } = useIssue(owner, repo, number);
66 const [commentText, setCommentText] = useState('');
67 const scrollRef = useRef<ScrollView>(null);
68
69 async function handleSubmit() {
70 const trimmed = commentText.trim();
71 if (!trimmed) return;
72 try {
73 await addComment(trimmed);
74 setCommentText('');
75 scrollRef.current?.scrollToEnd({ animated: true });
76 } catch (err) {
77 Alert.alert('Error', err instanceof Error ? err.message : 'Failed to post comment');
78 }
79 }
80
81 if (loading) return <LoadingSpinner fullScreen />;
82 if (error || !issue) return <ErrorState message={error || 'Issue not found'} onRetry={refresh} />;
83
84 const isOpen = issue.state === 'open';
85
86 return (
87 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
88 <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.kav}>
89 <ScrollView ref={scrollRef} style={styles.scroll} contentContainerStyle={styles.content}>
90 {/* Issue header */}
91 <View style={styles.issueHeader}>
92 <View style={[styles.stateBadge, { backgroundColor: isOpen ? colors.green + '22' : colors.textMuted + '22' }]}>
93 <View style={[styles.stateDot, { backgroundColor: isOpen ? colors.green : colors.textMuted }]} />
94 <Text style={[styles.stateText, { color: isOpen ? colors.green : colors.textMuted }]}>
95 {isOpen ? 'Open' : 'Closed'}
96 </Text>
97 </View>
98 <Text style={styles.number}>#{issue.number}</Text>
99 </View>
100
101 <Text style={styles.title}>{issue.title}</Text>
102
103 <Text style={styles.meta}>
104 opened {timeAgo(issue.createdAt)}
105 </Text>
106
107 {/* Body */}
108 {issue.body ? (
109 <View style={styles.bodyWrap}>
110 {renderBody(issue.body)}
111 </View>
112 ) : (
113 <Text style={styles.noBody}>No description provided.</Text>
114 )}
115
116 {/* Comments */}
117 {comments.length > 0 && (
118 <View style={styles.commentsSection}>
119 <Text style={styles.commentsTitle}>{comments.length} comment{comments.length !== 1 ? 's' : ''}</Text>
120 {comments.map((comment) => (
121 <View key={comment.id} style={styles.commentCard}>
122 <View style={styles.commentHeader}>
123 <Text style={styles.commentAuthor}>
124 {comment.author?.username ?? 'Unknown'}
125 </Text>
126 <Text style={styles.commentTime}>{timeAgo(comment.createdAt)}</Text>
127 </View>
128 <View style={styles.commentBody}>
129 {renderBody(comment.body)}
130 </View>
131 </View>
132 ))}
133 </View>
134 )}
135 </ScrollView>
136
137 {/* Comment composer */}
138 <View style={styles.composer}>
139 <TextInput
140 style={styles.composerInput}
141 value={commentText}
142 onChangeText={setCommentText}
143 placeholder="Leave a comment..."
144 placeholderTextColor={colors.textMuted}
145 multiline
146 maxLength={65536}
147 />
148 <TouchableOpacity
149 style={[styles.sendBtn, (!commentText.trim() || submitting) && styles.sendBtnDisabled]}
150 onPress={handleSubmit}
151 disabled={!commentText.trim() || submitting}
152 activeOpacity={0.8}
153 >
154 {submitting ? (
155 <ActivityIndicator color={colors.white} size="small" />
156 ) : (
157 <Text style={styles.sendBtnText}>Comment</Text>
158 )}
159 </TouchableOpacity>
160 </View>
161 </KeyboardAvoidingView>
162 </SafeAreaView>
163 );
164}
165
166const styles = StyleSheet.create({
167 safe: { flex: 1, backgroundColor: colors.bg },
168 kav: { flex: 1 },
169 scroll: { flex: 1 },
170 content: { padding: 16, paddingBottom: 8 },
171 issueHeader: {
172 flexDirection: 'row',
173 alignItems: 'center',
174 gap: 10,
175 marginBottom: 10,
176 },
177 stateBadge: {
178 flexDirection: 'row',
179 alignItems: 'center',
180 gap: 6,
181 borderRadius: 12,
182 paddingHorizontal: 10,
183 paddingVertical: 4,
184 },
185 stateDot: { width: 8, height: 8, borderRadius: 4 },
186 stateText: { fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
187 number: { color: colors.textMuted, fontSize: fontSizes.sm },
188 title: {
189 fontSize: fontSizes.xl,
190 fontWeight: fontWeights.bold,
191 color: colors.text,
192 lineHeight: 28,
193 marginBottom: 6,
194 },
195 meta: { color: colors.textMuted, fontSize: fontSizes.sm, marginBottom: 16 },
196 bodyWrap: {
197 backgroundColor: colors.bgSurface,
198 borderRadius: 8,
199 padding: 14,
200 marginBottom: 24,
201 borderWidth: 1,
202 borderColor: colors.border,
203 },
204 noBody: { color: colors.textMuted, fontSize: fontSizes.sm, fontStyle: 'italic', marginBottom: 24 },
205 h1: { color: colors.text, fontSize: fontSizes.xl, fontWeight: fontWeights.bold, marginBottom: 6 },
206 h2: { color: colors.text, fontSize: fontSizes.lg, fontWeight: fontWeights.bold, marginBottom: 4 },
207 h3: { color: colors.text, fontSize: fontSizes.md, fontWeight: fontWeights.semibold, marginBottom: 3 },
208 bodyLine: { color: colors.text, fontSize: fontSizes.sm, lineHeight: 20 },
209 codeLine: { color: colors.green, fontSize: fontSizes.xs, fontFamily: 'monospace', backgroundColor: colors.bgSecondary, paddingHorizontal: 4 },
210 emptyLine: { height: 8 },
211 commentsSection: { borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 20 },
212 commentsTitle: { color: colors.textMuted, fontSize: fontSizes.sm, fontWeight: fontWeights.medium, marginBottom: 14 },
213 commentCard: {
214 backgroundColor: colors.bgSurface,
215 borderRadius: 8,
216 padding: 12,
217 marginBottom: 12,
218 borderWidth: 1,
219 borderColor: colors.border,
220 },
221 commentHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 },
222 commentAuthor: { color: colors.accent, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
223 commentTime: { color: colors.textMuted, fontSize: fontSizes.xs },
224 commentBody: {},
225 composer: {
226 flexDirection: 'row',
227 alignItems: 'flex-end',
228 padding: 12,
229 borderTopWidth: 1,
230 borderTopColor: colors.border,
231 gap: 10,
232 backgroundColor: colors.bgSecondary,
233 },
234 composerInput: {
235 flex: 1,
236 backgroundColor: colors.bgSurface,
237 borderWidth: 1,
238 borderColor: colors.border,
239 borderRadius: 8,
240 paddingHorizontal: 12,
241 paddingVertical: 10,
242 color: colors.text,
243 fontSize: fontSizes.sm,
244 maxHeight: 120,
245 minHeight: 40,
246 },
247 sendBtn: {
248 backgroundColor: colors.accent,
249 borderRadius: 8,
250 paddingHorizontal: 16,
251 paddingVertical: 10,
252 minHeight: 40,
253 justifyContent: 'center',
254 },
255 sendBtnDisabled: { opacity: 0.5 },
256 sendBtnText: { color: colors.white, fontSize: fontSizes.sm, fontWeight: fontWeights.semibold },
257});
Addedmobile/src/screens/IssueListScreen.tsx+149−0View fileUnifiedSplit
1import React, { useState } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 RefreshControl,
8 TouchableOpacity,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { type RouteProp } from '@react-navigation/native';
13import { colors } from '../theme/colors';
14import { fontSizes, fontWeights } from '../theme/typography';
15import { useIssues } from '../hooks/useIssues';
16import { IssueRow } from '../components/IssueRow';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorState } from '../components/ErrorState';
19import { EmptyState } from '../components/EmptyState';
20import { type MainStackParamList } from '../navigation/MainTabNavigator';
21
22type Props = {
23 navigation: NativeStackNavigationProp<MainStackParamList, 'IssueList'>;
24 route: RouteProp<MainStackParamList, 'IssueList'>;
25};
26
27export function IssueListScreen({ navigation, route }: Props) {
28 const { owner, repo } = route.params;
29 const [stateFilter, setStateFilter] = useState<'open' | 'closed'>('open');
30 const [refreshing, setRefreshing] = useState(false);
31 const { issues, loading, error, refresh } = useIssues(owner, repo, stateFilter);
32
33 async function onRefresh() {
34 setRefreshing(true);
35 await refresh();
36 setRefreshing(false);
37 }
38
39 if (loading && !refreshing && issues.length === 0) {
40 return <LoadingSpinner fullScreen />;
41 }
42
43 if (error && issues.length === 0) {
44 return <ErrorState message={error} onRetry={refresh} />;
45 }
46
47 return (
48 <SafeAreaView style={styles.safe} edges={['top']}>
49 {/* State toggle */}
50 <View style={styles.toggleBar}>
51 <TouchableOpacity
52 style={[styles.toggleBtn, stateFilter === 'open' && styles.toggleActive]}
53 onPress={() => setStateFilter('open')}
54 activeOpacity={0.75}
55 >
56 <View style={[styles.dot, { backgroundColor: colors.green }]} />
57 <Text style={[styles.toggleText, stateFilter === 'open' && styles.toggleTextActive]}>
58 Open
59 </Text>
60 </TouchableOpacity>
61 <TouchableOpacity
62 style={[styles.toggleBtn, stateFilter === 'closed' && styles.toggleActive]}
63 onPress={() => setStateFilter('closed')}
64 activeOpacity={0.75}
65 >
66 <View style={[styles.dot, { backgroundColor: colors.textMuted }]} />
67 <Text style={[styles.toggleText, stateFilter === 'closed' && styles.toggleTextActive]}>
68 Closed
69 </Text>
70 </TouchableOpacity>
71 <Text style={styles.repoBadge}>{owner}/{repo}</Text>
72 </View>
73
74 <FlatList
75 data={issues}
76 keyExtractor={(item) => item.id}
77 renderItem={({ item }) => (
78 <IssueRow
79 issue={item}
80 onPress={() =>
81 navigation.navigate('IssueDetail', {
82 owner,
83 repo,
84 number: item.number,
85 })
86 }
87 />
88 )}
89 refreshControl={
90 <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
91 }
92 ListEmptyComponent={
93 <EmptyState
94 title={`No ${stateFilter} issues`}
95 subtitle="Issues will appear here when created"
96 icon="●"
97 />
98 }
99 />
100 </SafeAreaView>
101 );
102}
103
104const styles = StyleSheet.create({
105 safe: {
106 flex: 1,
107 backgroundColor: colors.bg,
108 },
109 toggleBar: {
110 flexDirection: 'row',
111 alignItems: 'center',
112 padding: 12,
113 gap: 8,
114 borderBottomWidth: 1,
115 borderBottomColor: colors.border,
116 },
117 toggleBtn: {
118 flexDirection: 'row',
119 alignItems: 'center',
120 gap: 6,
121 paddingHorizontal: 12,
122 paddingVertical: 6,
123 borderRadius: 20,
124 borderWidth: 1,
125 borderColor: 'transparent',
126 },
127 toggleActive: {
128 borderColor: colors.border,
129 backgroundColor: colors.bgSurface,
130 },
131 dot: {
132 width: 8,
133 height: 8,
134 borderRadius: 4,
135 },
136 toggleText: {
137 color: colors.textMuted,
138 fontSize: fontSizes.sm,
139 fontWeight: fontWeights.medium,
140 },
141 toggleTextActive: {
142 color: colors.text,
143 },
144 repoBadge: {
145 marginLeft: 'auto',
146 color: colors.textMuted,
147 fontSize: fontSizes.xs,
148 },
149});
Addedmobile/src/screens/NotificationsScreen.tsx+102−0View fileUnifiedSplit
1import React, { useContext, useEffect } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 TouchableOpacity,
8 RefreshControl,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { colors } from '../theme/colors';
12import { fontSizes, fontWeights } from '../theme/typography';
13import { AuthContext } from '../navigation/RootNavigator';
14import { useUserRepos } from '../hooks/useRepo';
15import { useNotifications } from '../hooks/useNotifications';
16import { NotifRow } from '../components/NotifRow';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { EmptyState } from '../components/EmptyState';
19
20export function NotificationsScreen() {
21 const { user } = useContext(AuthContext);
22 const { repos } = useUserRepos(user?.username ?? null);
23
24 const repoRefs = repos.slice(0, 5).map((r) => ({
25 owner: user?.username ?? '',
26 name: r.name,
27 }));
28
29 const { notifications, loading, error, refresh, markAllRead, unreadCount } = useNotifications(repoRefs);
30
31 useEffect(() => {
32 if (repos.length > 0) {
33 refresh();
34 }
35 }, [repos.length]);
36
37 return (
38 <SafeAreaView style={styles.safe} edges={['top']}>
39 {/* Header */}
40 <View style={styles.header}>
41 <Text style={styles.title}>
42 Notifications{unreadCount > 0 ? ` (${unreadCount})` : ''}
43 </Text>
44 {unreadCount > 0 && (
45 <TouchableOpacity onPress={markAllRead} style={styles.markReadBtn} activeOpacity={0.75}>
46 <Text style={styles.markReadText}>Mark all read</Text>
47 </TouchableOpacity>
48 )}
49 </View>
50
51 {loading && notifications.length === 0 ? (
52 <LoadingSpinner size="large" />
53 ) : (
54 <FlatList
55 data={notifications}
56 keyExtractor={(item) => item.id}
57 renderItem={({ item }) => <NotifRow notification={item} />}
58 refreshControl={
59 <RefreshControl refreshing={loading} onRefresh={refresh} tintColor={colors.accent} />
60 }
61 ListEmptyComponent={
62 <EmptyState
63 title="No notifications"
64 subtitle="Activity from your repos will appear here"
65 icon="🔔"
66 />
67 }
68 />
69 )}
70 </SafeAreaView>
71 );
72}
73
74const styles = StyleSheet.create({
75 safe: { flex: 1, backgroundColor: colors.bg },
76 header: {
77 flexDirection: 'row',
78 justifyContent: 'space-between',
79 alignItems: 'center',
80 paddingHorizontal: 16,
81 paddingTop: 16,
82 paddingBottom: 12,
83 borderBottomWidth: 1,
84 borderBottomColor: colors.border,
85 },
86 title: {
87 color: colors.text,
88 fontSize: fontSizes.xl,
89 fontWeight: fontWeights.bold,
90 },
91 markReadBtn: {
92 paddingHorizontal: 12,
93 paddingVertical: 6,
94 backgroundColor: colors.accentDim,
95 borderRadius: 16,
96 },
97 markReadText: {
98 color: colors.accent,
99 fontSize: fontSizes.sm,
100 fontWeight: fontWeights.medium,
101 },
102});
Addedmobile/src/screens/PullDetailScreen.tsx+210−0View fileUnifiedSplit
1import React from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7} from 'react-native';
8import { SafeAreaView } from 'react-native-safe-area-context';
9import { type RouteProp } from '@react-navigation/native';
10import { colors } from '../theme/colors';
11import { fontSizes, fontWeights, fonts } from '../theme/typography';
12import { usePull } from '../hooks/usePulls';
13import { LoadingSpinner } from '../components/LoadingSpinner';
14import { ErrorState } from '../components/ErrorState';
15import { type MainStackParamList } from '../navigation/MainTabNavigator';
16
17type Props = {
18 route: RouteProp<MainStackParamList, 'PullDetail'>;
19};
20
21function stateColor(state: string): string {
22 switch (state) {
23 case 'open': return colors.green;
24 case 'merged': return colors.accent;
25 case 'closed': return colors.red;
26 default: return colors.textMuted;
27 }
28}
29
30function stateLabel(state: string): string {
31 switch (state) {
32 case 'open': return 'Open';
33 case 'merged': return 'Merged';
34 case 'closed': return 'Closed';
35 default: return state;
36 }
37}
38
39function timeAgo(dateStr: string): string {
40 const diff = Date.now() - new Date(dateStr).getTime();
41 const minutes = Math.floor(diff / 60000);
42 if (minutes < 60) return `${minutes}m ago`;
43 const hours = Math.floor(minutes / 60);
44 if (hours < 24) return `${hours}h ago`;
45 const days = Math.floor(hours / 24);
46 return `${days}d ago`;
47}
48
49function renderBody(text: string | null) {
50 if (!text) return null;
51 return text.split('\n').map((line, i) => {
52 if (!line.trim()) return <Text key={i} style={{ height: 8 }} />;
53 return <Text key={i} style={styles.bodyLine}>{line}</Text>;
54 });
55}
56
57export function PullDetailScreen({ route }: Props) {
58 const { owner, repo, number } = route.params;
59 const { pull, comments, loading, error, refresh } = usePull(owner, repo, number);
60
61 if (loading) return <LoadingSpinner fullScreen />;
62 if (error || !pull) return <ErrorState message={error || 'PR not found'} onRetry={refresh} />;
63
64 const sc = stateColor(pull.state);
65
66 return (
67 <SafeAreaView style={styles.safe} edges={['top']}>
68 <ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
69 {/* Header */}
70 <View style={styles.prHeader}>
71 <View style={[styles.stateBadge, { backgroundColor: sc + '22', borderColor: sc }]}>
72 <Text style={[styles.stateText, { color: sc }]}>{stateLabel(pull.state)}</Text>
73 </View>
74 <Text style={styles.prNum}>#{pull.number}</Text>
75 </View>
76
77 <Text style={styles.title}>{pull.title}</Text>
78
79 <View style={styles.branchRow}>
80 <Text style={styles.branch}>{pull.headBranch}</Text>
81 <Text style={styles.branchArrow}></Text>
82 <Text style={styles.branch}>{pull.baseBranch}</Text>
83 </View>
84
85 <Text style={styles.meta}>
86 opened {timeAgo(pull.createdAt)}
87 {pull.mergedAt ? ` · merged ${timeAgo(pull.mergedAt)}` : ''}
88 {pull.closedAt && !pull.mergedAt ? ` · closed ${timeAgo(pull.closedAt)}` : ''}
89 </Text>
90
91 {/* Description */}
92 {pull.body ? (
93 <View style={styles.bodyWrap}>
94 {renderBody(pull.body)}
95 </View>
96 ) : (
97 <Text style={styles.noBody}>No description provided.</Text>
98 )}
99
100 {/* Comments */}
101 {comments.length > 0 && (
102 <View style={styles.commentsSection}>
103 <Text style={styles.commentsTitle}>
104 {comments.length} comment{comments.length !== 1 ? 's' : ''}
105 </Text>
106 {comments.map((c) => (
107 <View key={c.id} style={[styles.commentCard, c.isAiReview && styles.aiCommentCard]}>
108 <View style={styles.commentHeader}>
109 <Text style={styles.commentAuthor}>
110 {c.isAiReview ? '⬡ AI Review' : (c.author?.username ?? 'Unknown')}
111 </Text>
112 <Text style={styles.commentTime}>{timeAgo(c.createdAt)}</Text>
113 </View>
114 {c.filePath && (
115 <Text style={styles.filePath}>
116 {c.filePath}{c.line != null ? `:${c.line}` : ''}
117 </Text>
118 )}
119 <Text style={styles.commentBody}>{c.body}</Text>
120 </View>
121 ))}
122 </View>
123 )}
124 </ScrollView>
125 </SafeAreaView>
126 );
127}
128
129const styles = StyleSheet.create({
130 safe: { flex: 1, backgroundColor: colors.bg },
131 scroll: { flex: 1 },
132 content: { padding: 16, paddingBottom: 32 },
133 prHeader: {
134 flexDirection: 'row',
135 alignItems: 'center',
136 gap: 10,
137 marginBottom: 10,
138 },
139 stateBadge: {
140 borderRadius: 12,
141 paddingHorizontal: 10,
142 paddingVertical: 4,
143 borderWidth: 1,
144 },
145 stateText: { fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
146 prNum: { color: colors.textMuted, fontSize: fontSizes.sm },
147 title: {
148 fontSize: fontSizes.xl,
149 fontWeight: fontWeights.bold,
150 color: colors.text,
151 lineHeight: 28,
152 marginBottom: 8,
153 },
154 branchRow: {
155 flexDirection: 'row',
156 alignItems: 'center',
157 marginBottom: 6,
158 flexWrap: 'wrap',
159 },
160 branch: {
161 color: colors.accent,
162 fontSize: fontSizes.sm,
163 fontFamily: fonts.mono,
164 backgroundColor: colors.accentDim,
165 paddingHorizontal: 6,
166 paddingVertical: 2,
167 borderRadius: 4,
168 },
169 branchArrow: { color: colors.textMuted, fontSize: fontSizes.sm, marginHorizontal: 4 },
170 meta: { color: colors.textMuted, fontSize: fontSizes.sm, marginBottom: 16 },
171 bodyWrap: {
172 backgroundColor: colors.bgSurface,
173 borderRadius: 8,
174 padding: 14,
175 marginBottom: 24,
176 borderWidth: 1,
177 borderColor: colors.border,
178 },
179 bodyLine: { color: colors.text, fontSize: fontSizes.sm, lineHeight: 20 },
180 noBody: { color: colors.textMuted, fontSize: fontSizes.sm, fontStyle: 'italic', marginBottom: 24 },
181 commentsSection: { borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 20 },
182 commentsTitle: { color: colors.textMuted, fontSize: fontSizes.sm, fontWeight: fontWeights.medium, marginBottom: 14 },
183 commentCard: {
184 backgroundColor: colors.bgSurface,
185 borderRadius: 8,
186 padding: 12,
187 marginBottom: 12,
188 borderWidth: 1,
189 borderColor: colors.border,
190 },
191 aiCommentCard: {
192 borderColor: colors.accent + '55',
193 backgroundColor: colors.accentDim,
194 },
195 commentHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
196 commentAuthor: { color: colors.accent, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
197 commentTime: { color: colors.textMuted, fontSize: fontSizes.xs },
198 filePath: {
199 color: colors.textMuted,
200 fontSize: fontSizes.xs,
201 fontFamily: fonts.mono,
202 marginBottom: 6,
203 backgroundColor: colors.bgSecondary,
204 paddingHorizontal: 6,
205 paddingVertical: 2,
206 borderRadius: 4,
207 alignSelf: 'flex-start',
208 },
209 commentBody: { color: colors.text, fontSize: fontSizes.sm, lineHeight: 20 },
210});
Addedmobile/src/screens/PullListScreen.tsx+134−0View fileUnifiedSplit
1import React, { useState } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 RefreshControl,
8 TouchableOpacity,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { type RouteProp } from '@react-navigation/native';
13import { colors } from '../theme/colors';
14import { fontSizes, fontWeights } from '../theme/typography';
15import { usePulls } from '../hooks/usePulls';
16import { PullRow } from '../components/PullRow';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorState } from '../components/ErrorState';
19import { EmptyState } from '../components/EmptyState';
20import { type MainStackParamList } from '../navigation/MainTabNavigator';
21
22type PullState = 'open' | 'closed' | 'merged';
23
24type Props = {
25 navigation: NativeStackNavigationProp<MainStackParamList, 'PullList'>;
26 route: RouteProp<MainStackParamList, 'PullList'>;
27};
28
29const STATE_LABELS: PullState[] = ['open', 'merged', 'closed'];
30
31export function PullListScreen({ navigation, route }: Props) {
32 const { owner, repo } = route.params;
33 const [stateFilter, setStateFilter] = useState<PullState>('open');
34 const [refreshing, setRefreshing] = useState(false);
35 const { pulls, loading, error, refresh } = usePulls(owner, repo, stateFilter);
36
37 async function onRefresh() {
38 setRefreshing(true);
39 await refresh();
40 setRefreshing(false);
41 }
42
43 if (loading && !refreshing && pulls.length === 0) {
44 return <LoadingSpinner fullScreen />;
45 }
46
47 if (error && pulls.length === 0) {
48 return <ErrorState message={error} onRetry={refresh} />;
49 }
50
51 return (
52 <SafeAreaView style={styles.safe} edges={['top']}>
53 <View style={styles.filterBar}>
54 {STATE_LABELS.map((s) => (
55 <TouchableOpacity
56 key={s}
57 style={[styles.filterBtn, stateFilter === s && styles.filterBtnActive]}
58 onPress={() => setStateFilter(s)}
59 activeOpacity={0.75}
60 >
61 <Text style={[styles.filterText, stateFilter === s && styles.filterTextActive]}>
62 {s.charAt(0).toUpperCase() + s.slice(1)}
63 </Text>
64 </TouchableOpacity>
65 ))}
66 <Text style={styles.repoBadge}>{owner}/{repo}</Text>
67 </View>
68
69 <FlatList
70 data={pulls}
71 keyExtractor={(item) => item.id}
72 renderItem={({ item }) => (
73 <PullRow
74 pull={item}
75 onPress={() =>
76 navigation.navigate('PullDetail', {
77 owner,
78 repo,
79 number: item.number,
80 })
81 }
82 />
83 )}
84 refreshControl={
85 <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
86 }
87 ListEmptyComponent={
88 <EmptyState
89 title={`No ${stateFilter} pull requests`}
90 subtitle="Pull requests will appear here when created"
91 icon="⑂"
92 />
93 }
94 />
95 </SafeAreaView>
96 );
97}
98
99const styles = StyleSheet.create({
100 safe: { flex: 1, backgroundColor: colors.bg },
101 filterBar: {
102 flexDirection: 'row',
103 alignItems: 'center',
104 padding: 12,
105 gap: 6,
106 borderBottomWidth: 1,
107 borderBottomColor: colors.border,
108 flexWrap: 'wrap',
109 },
110 filterBtn: {
111 paddingHorizontal: 12,
112 paddingVertical: 6,
113 borderRadius: 20,
114 borderWidth: 1,
115 borderColor: 'transparent',
116 },
117 filterBtnActive: {
118 borderColor: colors.border,
119 backgroundColor: colors.bgSurface,
120 },
121 filterText: {
122 color: colors.textMuted,
123 fontSize: fontSizes.sm,
124 fontWeight: fontWeights.medium,
125 },
126 filterTextActive: {
127 color: colors.text,
128 },
129 repoBadge: {
130 marginLeft: 'auto',
131 color: colors.textMuted,
132 fontSize: fontSizes.xs,
133 },
134});
Addedmobile/src/screens/RepoDetailScreen.tsx+323−0View fileUnifiedSplit
1import React, { useState } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7 TouchableOpacity,
8 FlatList,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { type RouteProp } from '@react-navigation/native';
13import { colors } from '../theme/colors';
14import { fontSizes, fontWeights, fonts } from '../theme/typography';
15import { useRepo, useFileTree, useCommits } from '../hooks/useRepo';
16import { useIssues } from '../hooks/useIssues';
17import { usePulls } from '../hooks/usePulls';
18import { LoadingSpinner } from '../components/LoadingSpinner';
19import { ErrorState } from '../components/ErrorState';
20import { CommitRow } from '../components/CommitRow';
21import { IssueRow } from '../components/IssueRow';
22import { PullRow } from '../components/PullRow';
23import { type MainStackParamList } from '../navigation/MainTabNavigator';
24
25type Props = {
26 navigation: NativeStackNavigationProp<MainStackParamList, 'RepoDetail'>;
27 route: RouteProp<MainStackParamList, 'RepoDetail'>;
28};
29
30type Tab = 'Code' | 'Issues' | 'PRs' | 'Commits';
31
32const TABS: Tab[] = ['Code', 'Issues', 'PRs', 'Commits'];
33
34export function RepoDetailScreen({ navigation, route }: Props) {
35 const { owner, repo: repoName } = route.params;
36 const [activeTab, setActiveTab] = useState<Tab>('Code');
37
38 const { repo, loading: repoLoading, error: repoError } = useRepo(owner, repoName);
39 const { tree, loading: treeLoading } = useFileTree(owner, repoName, repo?.defaultBranch || 'HEAD');
40 const { commits, loading: commitsLoading } = useCommits(owner, repoName);
41 const { issues, loading: issuesLoading } = useIssues(owner, repoName, 'open');
42 const { pulls, loading: pullsLoading } = usePulls(owner, repoName, 'open');
43
44 if (repoLoading) return <LoadingSpinner fullScreen />;
45 if (repoError || !repo) return <ErrorState message={repoError || 'Repo not found'} />;
46
47 const sorted = [...tree].sort((a, b) => {
48 if (a.type === 'tree' && b.type !== 'tree') return -1;
49 if (a.type !== 'tree' && b.type === 'tree') return 1;
50 return a.name.localeCompare(b.name);
51 });
52
53 return (
54 <SafeAreaView style={styles.safe} edges={['top']}>
55 <ScrollView stickyHeaderIndices={[1]}>
56 {/* Repo header */}
57 <View style={styles.header}>
58 <Text style={styles.repoName}>
59 <Text style={styles.owner}>{owner}/</Text>
60 {repo.name}
61 </Text>
62 {repo.description ? (
63 <Text style={styles.desc}>{repo.description}</Text>
64 ) : null}
65
66 <View style={styles.statsRow}>
67 <View style={styles.stat}>
68 <Text style={styles.statIcon}></Text>
69 <Text style={styles.statVal}>{repo.starCount}</Text>
70 </View>
71 <View style={styles.stat}>
72 <Text style={styles.statIcon}></Text>
73 <Text style={styles.statVal}>{repo.forkCount}</Text>
74 </View>
75 <View style={styles.stat}>
76 <Text style={styles.statIcon}></Text>
77 <Text style={styles.statVal}>{repo.issueCount} issues</Text>
78 </View>
79 <View style={styles.branchBadge}>
80 <Text style={styles.branchText}>{repo.defaultBranch}</Text>
81 </View>
82 </View>
83 </View>
84
85 {/* Tab bar — sticky */}
86 <View style={styles.tabBar}>
87 {TABS.map((tab) => (
88 <TouchableOpacity
89 key={tab}
90 style={[styles.tab, activeTab === tab && styles.tabActive]}
91 onPress={() => setActiveTab(tab)}
92 activeOpacity={0.75}
93 >
94 <Text style={[styles.tabText, activeTab === tab && styles.tabTextActive]}>
95 {tab}
96 </Text>
97 </TouchableOpacity>
98 ))}
99 </View>
100
101 {/* Code tab */}
102 {activeTab === 'Code' && (
103 <View style={styles.fileList}>
104 {treeLoading ? (
105 <LoadingSpinner size="small" />
106 ) : sorted.length === 0 ? (
107 <Text style={styles.emptyText}>Empty repository</Text>
108 ) : (
109 sorted.map((entry) => (
110 <TouchableOpacity
111 key={entry.path}
112 style={styles.fileRow}
113 activeOpacity={0.75}
114 onPress={() => {
115 if (entry.type === 'blob') {
116 navigation.navigate('FileViewer', {
117 owner,
118 repo: repoName,
119 path: entry.path,
120 ref: repo.defaultBranch,
121 });
122 }
123 }}
124 >
125 <Text style={styles.fileIcon}>{entry.type === 'tree' ? '📁' : '📄'}</Text>
126 <Text style={styles.fileName}>{entry.name}</Text>
127 {entry.size !== undefined && entry.type === 'blob' && (
128 <Text style={styles.fileSize}>{formatSize(entry.size)}</Text>
129 )}
130 </TouchableOpacity>
131 ))
132 )}
133 </View>
134 )}
135
136 {/* Issues tab */}
137 {activeTab === 'Issues' && (
138 <View>
139 {issuesLoading ? (
140 <LoadingSpinner size="small" />
141 ) : issues.length === 0 ? (
142 <Text style={styles.emptyText}>No open issues</Text>
143 ) : (
144 issues.map((issue) => (
145 <IssueRow
146 key={issue.id}
147 issue={issue}
148 onPress={() =>
149 navigation.navigate('IssueDetail', {
150 owner,
151 repo: repoName,
152 number: issue.number,
153 })
154 }
155 />
156 ))
157 )}
158 </View>
159 )}
160
161 {/* PRs tab */}
162 {activeTab === 'PRs' && (
163 <View>
164 {pullsLoading ? (
165 <LoadingSpinner size="small" />
166 ) : pulls.length === 0 ? (
167 <Text style={styles.emptyText}>No open pull requests</Text>
168 ) : (
169 pulls.map((pull) => (
170 <PullRow
171 key={pull.id}
172 pull={pull}
173 onPress={() =>
174 navigation.navigate('PullDetail', {
175 owner,
176 repo: repoName,
177 number: pull.number,
178 })
179 }
180 />
181 ))
182 )}
183 </View>
184 )}
185
186 {/* Commits tab */}
187 {activeTab === 'Commits' && (
188 <View>
189 {commitsLoading ? (
190 <LoadingSpinner size="small" />
191 ) : commits.length === 0 ? (
192 <Text style={styles.emptyText}>No commits yet</Text>
193 ) : (
194 commits.map((commit) => <CommitRow key={commit.sha} commit={commit} />)
195 )}
196 </View>
197 )}
198 </ScrollView>
199 </SafeAreaView>
200 );
201}
202
203function formatSize(bytes: number): string {
204 if (bytes < 1024) return `${bytes} B`;
205 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
206 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
207}
208
209const styles = StyleSheet.create({
210 safe: {
211 flex: 1,
212 backgroundColor: colors.bg,
213 },
214 header: {
215 padding: 16,
216 borderBottomWidth: 1,
217 borderBottomColor: colors.border,
218 },
219 repoName: {
220 fontSize: fontSizes.xl,
221 fontWeight: fontWeights.bold,
222 color: colors.text,
223 marginBottom: 6,
224 },
225 owner: {
226 color: colors.textMuted,
227 fontWeight: fontWeights.regular,
228 },
229 desc: {
230 color: colors.textMuted,
231 fontSize: fontSizes.sm,
232 lineHeight: 18,
233 marginBottom: 12,
234 },
235 statsRow: {
236 flexDirection: 'row',
237 alignItems: 'center',
238 gap: 12,
239 flexWrap: 'wrap',
240 },
241 stat: {
242 flexDirection: 'row',
243 alignItems: 'center',
244 gap: 4,
245 },
246 statIcon: {
247 color: colors.textMuted,
248 fontSize: fontSizes.sm,
249 },
250 statVal: {
251 color: colors.textMuted,
252 fontSize: fontSizes.sm,
253 },
254 branchBadge: {
255 backgroundColor: colors.bgSecondary,
256 borderRadius: 12,
257 paddingHorizontal: 8,
258 paddingVertical: 3,
259 borderWidth: 1,
260 borderColor: colors.border,
261 },
262 branchText: {
263 color: colors.textMuted,
264 fontSize: fontSizes.xs,
265 fontFamily: fonts.mono,
266 },
267 tabBar: {
268 flexDirection: 'row',
269 backgroundColor: colors.bg,
270 borderBottomWidth: 1,
271 borderBottomColor: colors.border,
272 },
273 tab: {
274 flex: 1,
275 paddingVertical: 12,
276 alignItems: 'center',
277 },
278 tabActive: {
279 borderBottomWidth: 2,
280 borderBottomColor: colors.accent,
281 },
282 tabText: {
283 color: colors.textMuted,
284 fontSize: fontSizes.sm,
285 fontWeight: fontWeights.medium,
286 },
287 tabTextActive: {
288 color: colors.accent,
289 },
290 fileList: {
291 padding: 4,
292 },
293 fileRow: {
294 flexDirection: 'row',
295 alignItems: 'center',
296 paddingHorizontal: 16,
297 paddingVertical: 10,
298 borderBottomWidth: 1,
299 borderBottomColor: colors.border,
300 gap: 10,
301 },
302 fileIcon: {
303 fontSize: 16,
304 width: 20,
305 textAlign: 'center',
306 },
307 fileName: {
308 flex: 1,
309 color: colors.text,
310 fontSize: fontSizes.sm,
311 fontFamily: fonts.mono,
312 },
313 fileSize: {
314 color: colors.textMuted,
315 fontSize: fontSizes.xs,
316 },
317 emptyText: {
318 color: colors.textMuted,
319 fontSize: fontSizes.sm,
320 textAlign: 'center',
321 padding: 32,
322 },
323});
Addedmobile/src/screens/RepoListScreen.tsx+146−0View fileUnifiedSplit
1import React, { useContext, useState } from 'react';
2import {
3 View,
4 Text,
5 FlatList,
6 StyleSheet,
7 RefreshControl,
8 TextInput,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
12import { colors } from '../theme/colors';
13import { fontSizes, fontWeights } from '../theme/typography';
14import { AuthContext } from '../navigation/RootNavigator';
15import { useUserRepos } from '../hooks/useRepo';
16import { RepoCard } from '../components/RepoCard';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorState } from '../components/ErrorState';
19import { EmptyState } from '../components/EmptyState';
20import { type MainStackParamList } from '../navigation/MainTabNavigator';
21
22interface Props {
23 navigation: NativeStackNavigationProp<MainStackParamList>;
24}
25
26export function RepoListScreen({ navigation }: Props) {
27 const { user } = useContext(AuthContext);
28 const { repos, loading, error, refresh } = useUserRepos(user?.username ?? null);
29 const [refreshing, setRefreshing] = useState(false);
30 const [query, setQuery] = useState('');
31
32 async function onRefresh() {
33 setRefreshing(true);
34 await refresh();
35 setRefreshing(false);
36 }
37
38 const filtered = query.trim()
39 ? repos.filter(
40 (r) =>
41 r.name.toLowerCase().includes(query.toLowerCase()) ||
42 (r.description && r.description.toLowerCase().includes(query.toLowerCase()))
43 )
44 : repos;
45
46 if (loading && !refreshing && repos.length === 0) {
47 return <LoadingSpinner fullScreen />;
48 }
49
50 if (error && repos.length === 0) {
51 return <ErrorState message={error} onRetry={refresh} />;
52 }
53
54 return (
55 <SafeAreaView style={styles.safe} edges={['top']}>
56 <View style={styles.header}>
57 <Text style={styles.title}>Repositories</Text>
58 <Text style={styles.count}>{repos.length} repos</Text>
59 </View>
60
61 <View style={styles.searchWrap}>
62 <TextInput
63 style={styles.search}
64 value={query}
65 onChangeText={setQuery}
66 placeholder="Search repositories..."
67 placeholderTextColor={colors.textMuted}
68 autoCapitalize="none"
69 autoCorrect={false}
70 clearButtonMode="while-editing"
71 />
72 </View>
73
74 <FlatList
75 data={filtered}
76 keyExtractor={(item) => item.id}
77 contentContainerStyle={styles.list}
78 renderItem={({ item }) => (
79 <RepoCard
80 repo={item}
81 onPress={() =>
82 navigation.navigate('RepoDetail', {
83 owner: user?.username ?? '',
84 repo: item.name,
85 })
86 }
87 />
88 )}
89 refreshControl={
90 <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.accent} />
91 }
92 ListEmptyComponent={
93 <EmptyState
94 title={query ? 'No matching repos' : 'No repositories'}
95 subtitle={query ? 'Try a different search term' : 'Create your first repo on gluecron.com'}
96 icon="⌥"
97 />
98 }
99 />
100 </SafeAreaView>
101 );
102}
103
104const styles = StyleSheet.create({
105 safe: {
106 flex: 1,
107 backgroundColor: colors.bg,
108 },
109 header: {
110 flexDirection: 'row',
111 justifyContent: 'space-between',
112 alignItems: 'center',
113 paddingHorizontal: 16,
114 paddingTop: 16,
115 paddingBottom: 8,
116 },
117 title: {
118 color: colors.text,
119 fontSize: fontSizes.xl,
120 fontWeight: fontWeights.bold,
121 },
122 count: {
123 color: colors.textMuted,
124 fontSize: fontSizes.sm,
125 },
126 searchWrap: {
127 paddingHorizontal: 16,
128 paddingBottom: 12,
129 },
130 search: {
131 backgroundColor: colors.bgSurface,
132 borderWidth: 1,
133 borderColor: colors.border,
134 borderRadius: 8,
135 paddingHorizontal: 12,
136 paddingVertical: 10,
137 color: colors.text,
138 fontSize: fontSizes.base,
139 minHeight: 40,
140 },
141 list: {
142 padding: 16,
143 paddingTop: 4,
144 paddingBottom: 32,
145 },
146});
Addedmobile/src/screens/SettingsScreen.tsx+248−0View fileUnifiedSplit
1import React, { useContext, useState } from 'react';
2import {
3 View,
4 Text,
5 StyleSheet,
6 TouchableOpacity,
7 ScrollView,
8 Alert,
9 TextInput,
10} from 'react-native';
11import { SafeAreaView } from 'react-native-safe-area-context';
12import { colors } from '../theme/colors';
13import { fontSizes, fontWeights } from '../theme/typography';
14import { AuthContext } from '../navigation/RootNavigator';
15import { getBaseUrl, setBaseUrl } from '../api/client';
16import { saveHost } from '../store/auth';
17
18const APP_VERSION = '1.0.0';
19
20export function SettingsScreen() {
21 const { user, logout } = useContext(AuthContext);
22 const [host, setHostState] = useState(getBaseUrl());
23 const [editingHost, setEditingHost] = useState(false);
24
25 async function handleLogout() {
26 Alert.alert(
27 'Sign out',
28 'Are you sure you want to sign out?',
29 [
30 { text: 'Cancel', style: 'cancel' },
31 {
32 text: 'Sign out',
33 style: 'destructive',
34 onPress: async () => {
35 await logout();
36 },
37 },
38 ]
39 );
40 }
41
42 async function handleSaveHost() {
43 const trimmed = host.trim().replace(/\/$/, '');
44 if (!trimmed) return;
45 setBaseUrl(trimmed);
46 await saveHost(trimmed);
47 setEditingHost(false);
48 }
49
50 return (
51 <SafeAreaView style={styles.safe} edges={['top']}>
52 <ScrollView contentContainerStyle={styles.content}>
53 {/* Title */}
54 <Text style={styles.pageTitle}>Settings</Text>
55
56 {/* User profile section */}
57 <View style={styles.section}>
58 <Text style={styles.sectionLabel}>Account</Text>
59 <View style={styles.card}>
60 <View style={styles.avatarRow}>
61 <View style={styles.avatar}>
62 <Text style={styles.avatarText}>
63 {(user?.displayName || user?.username || '?')[0].toUpperCase()}
64 </Text>
65 </View>
66 <View style={styles.userInfo}>
67 <Text style={styles.displayName}>{user?.displayName || user?.username}</Text>
68 <Text style={styles.username}>@{user?.username}</Text>
69 {user?.email && <Text style={styles.email}>{user.email}</Text>}
70 </View>
71 </View>
72 {user?.bio ? (
73 <Text style={styles.bio}>{user.bio}</Text>
74 ) : null}
75 </View>
76 </View>
77
78 {/* Host section */}
79 <View style={styles.section}>
80 <Text style={styles.sectionLabel}>Gluecron Host</Text>
81 <View style={styles.card}>
82 <View style={styles.hostRow}>
83 <Text style={styles.hostLabel}>Server URL</Text>
84 {!editingHost ? (
85 <View style={styles.hostValueRow}>
86 <Text style={styles.hostValue} numberOfLines={1}>{host}</Text>
87 <TouchableOpacity
88 onPress={() => setEditingHost(true)}
89 style={styles.editBtn}
90 activeOpacity={0.75}
91 >
92 <Text style={styles.editBtnText}>Edit</Text>
93 </TouchableOpacity>
94 </View>
95 ) : (
96 <View style={styles.hostEditRow}>
97 <TextInput
98 style={styles.hostInput}
99 value={host}
100 onChangeText={setHostState}
101 autoCapitalize="none"
102 autoCorrect={false}
103 keyboardType="url"
104 placeholder="https://gluecron.com"
105 placeholderTextColor={colors.textMuted}
106 returnKeyType="done"
107 onSubmitEditing={handleSaveHost}
108 />
109 <TouchableOpacity onPress={handleSaveHost} style={styles.saveBtn} activeOpacity={0.75}>
110 <Text style={styles.saveBtnText}>Save</Text>
111 </TouchableOpacity>
112 <TouchableOpacity
113 onPress={() => { setHostState(getBaseUrl()); setEditingHost(false); }}
114 style={styles.cancelBtn}
115 activeOpacity={0.75}
116 >
117 <Text style={styles.cancelBtnText}>Cancel</Text>
118 </TouchableOpacity>
119 </View>
120 )}
121 </View>
122 <Text style={styles.hostHint}>
123 Change this if you run a self-hosted Gluecron instance.
124 </Text>
125 </View>
126 </View>
127
128 {/* App info */}
129 <View style={styles.section}>
130 <Text style={styles.sectionLabel}>About</Text>
131 <View style={styles.card}>
132 <View style={styles.infoRow}>
133 <Text style={styles.infoLabel}>App version</Text>
134 <Text style={styles.infoValue}>{APP_VERSION}</Text>
135 </View>
136 <View style={styles.divider} />
137 <View style={styles.infoRow}>
138 <Text style={styles.infoLabel}>Platform</Text>
139 <Text style={styles.infoValue}>Gluecron Mobile</Text>
140 </View>
141 </View>
142 </View>
143
144 {/* Logout */}
145 <View style={styles.section}>
146 <TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
147 <Text style={styles.logoutText}>Sign out</Text>
148 </TouchableOpacity>
149 </View>
150 </ScrollView>
151 </SafeAreaView>
152 );
153}
154
155const styles = StyleSheet.create({
156 safe: { flex: 1, backgroundColor: colors.bg },
157 content: { padding: 16, paddingBottom: 40 },
158 pageTitle: {
159 color: colors.text,
160 fontSize: fontSizes.xl,
161 fontWeight: fontWeights.bold,
162 marginBottom: 20,
163 },
164 section: { marginBottom: 24 },
165 sectionLabel: {
166 color: colors.textMuted,
167 fontSize: fontSizes.xs,
168 fontWeight: fontWeights.semibold,
169 textTransform: 'uppercase',
170 letterSpacing: 0.8,
171 marginBottom: 8,
172 },
173 card: {
174 backgroundColor: colors.bgSurface,
175 borderRadius: 12,
176 padding: 16,
177 borderWidth: 1,
178 borderColor: colors.border,
179 },
180 avatarRow: {
181 flexDirection: 'row',
182 alignItems: 'center',
183 gap: 14,
184 marginBottom: 8,
185 },
186 avatar: {
187 width: 52,
188 height: 52,
189 borderRadius: 26,
190 backgroundColor: colors.accentDim,
191 alignItems: 'center',
192 justifyContent: 'center',
193 borderWidth: 2,
194 borderColor: colors.accent,
195 },
196 avatarText: {
197 color: colors.accent,
198 fontSize: fontSizes.xl,
199 fontWeight: fontWeights.bold,
200 },
201 userInfo: { flex: 1 },
202 displayName: {
203 color: colors.text,
204 fontSize: fontSizes.md,
205 fontWeight: fontWeights.semibold,
206 marginBottom: 2,
207 },
208 username: { color: colors.textMuted, fontSize: fontSizes.sm, marginBottom: 2 },
209 email: { color: colors.textMuted, fontSize: fontSizes.xs },
210 bio: { color: colors.textMuted, fontSize: fontSizes.sm, lineHeight: 18, marginTop: 4 },
211 hostRow: { marginBottom: 8 },
212 hostLabel: { color: colors.textMuted, fontSize: fontSizes.xs, fontWeight: fontWeights.medium, marginBottom: 6, textTransform: 'uppercase' },
213 hostValueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
214 hostValue: { color: colors.text, fontSize: fontSizes.sm, flex: 1 },
215 editBtn: { paddingHorizontal: 10, paddingVertical: 5, backgroundColor: colors.bgSecondary, borderRadius: 6 },
216 editBtnText: { color: colors.accent, fontSize: fontSizes.xs, fontWeight: fontWeights.medium },
217 hostEditRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
218 hostInput: {
219 flex: 1,
220 backgroundColor: colors.bgSecondary,
221 borderWidth: 1,
222 borderColor: colors.border,
223 borderRadius: 6,
224 paddingHorizontal: 10,
225 paddingVertical: 8,
226 color: colors.text,
227 fontSize: fontSizes.sm,
228 minHeight: 36,
229 },
230 saveBtn: { paddingHorizontal: 12, paddingVertical: 8, backgroundColor: colors.accent, borderRadius: 6 },
231 saveBtnText: { color: colors.white, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
232 cancelBtn: { paddingHorizontal: 10, paddingVertical: 8 },
233 cancelBtnText: { color: colors.textMuted, fontSize: fontSizes.sm },
234 hostHint: { color: colors.textMuted, fontSize: fontSizes.xs, lineHeight: 16 },
235 infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },
236 infoLabel: { color: colors.textMuted, fontSize: fontSizes.sm },
237 infoValue: { color: colors.text, fontSize: fontSizes.sm, fontWeight: fontWeights.medium },
238 divider: { height: 1, backgroundColor: colors.border, marginVertical: 8 },
239 logoutBtn: {
240 backgroundColor: colors.red + '22',
241 borderWidth: 1,
242 borderColor: colors.red + '55',
243 borderRadius: 10,
244 paddingVertical: 14,
245 alignItems: 'center',
246 },
247 logoutText: { color: colors.red, fontSize: fontSizes.base, fontWeight: fontWeights.semibold },
248});
Addedmobile/src/store/auth.ts+58−0View fileUnifiedSplit
1import * as SecureStore from 'expo-secure-store';
2import { api, type User } from '../api/client';
3
4const TOKEN_KEY = 'gluecron_token';
5const USER_KEY = 'gluecron_user';
6const HOST_KEY = 'gluecron_host';
7
8export async function saveToken(token: string): Promise<void> {
9 await SecureStore.setItemAsync(TOKEN_KEY, token);
10 api.setToken(token);
11}
12
13export async function loadToken(): Promise<string | null> {
14 const token = await SecureStore.getItemAsync(TOKEN_KEY);
15 if (token) {
16 api.setToken(token);
17 }
18 return token;
19}
20
21export async function clearToken(): Promise<void> {
22 await SecureStore.deleteItemAsync(TOKEN_KEY);
23 api.clearToken();
24}
25
26export async function saveUser(user: User): Promise<void> {
27 await SecureStore.setItemAsync(USER_KEY, JSON.stringify(user));
28}
29
30export async function loadUser(): Promise<User | null> {
31 const raw = await SecureStore.getItemAsync(USER_KEY);
32 if (!raw) return null;
33 try {
34 return JSON.parse(raw) as User;
35 } catch {
36 return null;
37 }
38}
39
40export async function clearUser(): Promise<void> {
41 await SecureStore.deleteItemAsync(USER_KEY);
42}
43
44export async function saveHost(host: string): Promise<void> {
45 await SecureStore.setItemAsync(HOST_KEY, host);
46}
47
48export async function loadHost(): Promise<string | null> {
49 return SecureStore.getItemAsync(HOST_KEY);
50}
51
52export async function clearAll(): Promise<void> {
53 await Promise.all([
54 SecureStore.deleteItemAsync(TOKEN_KEY),
55 SecureStore.deleteItemAsync(USER_KEY),
56 ]);
57 api.clearToken();
58}
Addedmobile/src/store/theme.ts+19−0View fileUnifiedSplit
1import { colors } from '../theme/colors';
2
3export type ThemeMode = 'dark' | 'light';
4
5// Currently only dark mode is supported — matches Gluecron's design.
6// Light mode scaffolding is here for future expansion.
7let _mode: ThemeMode = 'dark';
8
9export function getThemeMode(): ThemeMode {
10 return _mode;
11}
12
13export function setThemeMode(mode: ThemeMode): void {
14 _mode = mode;
15}
16
17export function getColors() {
18 return colors;
19}
Addedmobile/src/theme/colors.ts+16−0View fileUnifiedSplit
1export const colors = {
2 bg: '#08090f',
3 bgSecondary: '#0c0d14',
4 bgSurface: '#161826',
5 border: 'rgba(255,255,255,0.06)',
6 text: '#ededf2',
7 textMuted: '#8b8c9c',
8 accent: '#8c6dff',
9 accentDim: 'rgba(140,109,255,0.15)',
10 green: '#34d399',
11 red: '#f87171',
12 yellow: '#fbbf24',
13 blue: '#60a5fa',
14 white: '#ffffff',
15 black: '#000000',
16};
Addedmobile/src/theme/typography.ts+37−0View fileUnifiedSplit
1import { Platform } from 'react-native';
2
3export const fonts = {
4 mono: Platform.select({
5 ios: 'Menlo',
6 android: 'monospace',
7 default: 'monospace',
8 }),
9 sans: Platform.select({
10 ios: 'System',
11 android: 'Roboto',
12 default: 'System',
13 }),
14};
15
16export const fontSizes = {
17 xs: 11,
18 sm: 13,
19 base: 15,
20 md: 17,
21 lg: 20,
22 xl: 24,
23 xxl: 30,
24};
25
26export const fontWeights = {
27 regular: '400' as const,
28 medium: '500' as const,
29 semibold: '600' as const,
30 bold: '700' as const,
31};
32
33export const lineHeights = {
34 tight: 1.3,
35 normal: 1.5,
36 relaxed: 1.75,
37};
Addedmobile/tsconfig.json+10−0View fileUnifiedSplit
1{
2 "extends": "expo/tsconfig.base",
3 "compilerOptions": {
4 "strict": true,
5 "baseUrl": ".",
6 "paths": {
7 "@/*": ["src/*"]
8 }
9 }
10}
011