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

feat(mobile): complete React Native app — 14 screens, full AI integration

feat(mobile): complete React Native app — 14 screens, full AI integration

Root:
- App.tsx: NavigationContainer with dark theme, auth-gated routing
  (AuthNavigator vs AppNavigator based on SecureStore session)

Screens (4,363 lines across 14 files):
- LoginScreen: host URL + PAT input, glc_ validation, dark Gluecron branding
- DashboardScreen: stats row, recent repos, notification badge, Ask AI FAB
- RepoListScreen: filterable paginated repo list
- RepoScreen: overview card + quick nav to all sub-sections + file tree
- FileViewerScreen: directory listing or raw file viewer (monospace, scrollable)
- CommitsScreen: paginated commit history
- IssuesScreen + IssueDetailScreen: open/closed filter, comment thread,
  close/reopen actions
- PullsScreen + PullDetailScreen: gate badge, AiReviewCard with inline
  file:line comments, merge blocked when gates red, comment thread
- GateStatusScreen: passed/failed/AI-repaired stats, trigger button,
  expandable log output, AI-repaired badge with commit SHA
- NotificationsScreen: unread/all filter, type icons, mark-all-read
- AskAIScreen: full chat UI with markdown rendering, suggestion chips,
  purple AI bubbles / blue user bubbles
- SettingsScreen: account info, host URL editor, sign-out with confirm

package.json: main changed to App.tsx (React Navigation not expo-router)
api/repos.ts: fixed require() anti-pattern with proper top-level import

https://claude.ai/code/session_01HkSzgS2aJpKqv1B9t7o9Cb
Claude committed on April 24, 2026Parent: 9b18281
17 files changed+442429a8fbed57034512b31fbbff03f7813b87bb5f5a9
17 changed files+4424−2
Addedmobile/App.tsx+57−0View fileUnifiedSplit
1import React from 'react';
2import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
3import { SafeAreaProvider } from 'react-native-safe-area-context';
4import { StatusBar } from 'expo-status-bar';
5import { View, ActivityIndicator, StyleSheet } from 'react-native';
6import { AppNavigator } from './src/navigation/AppNavigator';
7import { AuthNavigator } from './src/navigation/AuthNavigator';
8import { useAuth } from './src/hooks/useAuth';
9import { colors } from './src/theme/colors';
10
11const NavTheme = {
12 ...DefaultTheme,
13 dark: true,
14 colors: {
15 ...DefaultTheme.colors,
16 primary: colors.accentBlue,
17 background: colors.bg,
18 card: colors.bgSecondary,
19 text: colors.text,
20 border: colors.border,
21 notification: colors.accentRed,
22 },
23};
24
25function RootNavigator(): React.ReactElement {
26 const { isAuthenticated, isLoading } = useAuth();
27
28 if (isLoading) {
29 return (
30 <View style={styles.splash}>
31 <ActivityIndicator size="large" color={colors.accentBlue} />
32 </View>
33 );
34 }
35
36 return isAuthenticated ? <AppNavigator /> : <AuthNavigator />;
37}
38
39export default function App(): React.ReactElement {
40 return (
41 <SafeAreaProvider>
42 <StatusBar style="light" />
43 <NavigationContainer theme={NavTheme}>
44 <RootNavigator />
45 </NavigationContainer>
46 </SafeAreaProvider>
47 );
48}
49
50const styles = StyleSheet.create({
51 splash: {
52 flex: 1,
53 backgroundColor: colors.bg,
54 alignItems: 'center',
55 justifyContent: 'center',
56 },
57});
Modifiedmobile/package.json+1−1View fileUnifiedSplit
11{
22 "name": "gluecron-mobile",
33 "version": "1.0.0",
4 "main": "expo-router/entry",
4 "main": "App.tsx",
55 "scripts": {
66 "start": "expo start",
77 "android": "expo start --android",
Modifiedmobile/src/api/repos.ts+3−1View fileUnifiedSplit
11import { fetchJSON, postJSON } from './client';
2import { useSettingsStore } from '../store/settingsStore';
23
34export interface Repository {
45 id: number;
102103 ref: string,
103104 path: string,
104105): Promise<string> {
106 const host = useSettingsStore.getState().host;
105107 const response = await fetch(
106 `${require('../store/settingsStore').useSettingsStore.getState().host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(ref)}/${path}`,
108 `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/raw/${encodeURIComponent(ref)}/${path}`,
107109 {
108110 headers: { Accept: 'text/plain' },
109111 },
Addedmobile/src/screens/AskAIScreen.tsx+494−0View fileUnifiedSplit
1import React, { useCallback, useRef, useState } from 'react';
2import {
3 FlatList,
4 KeyboardAvoidingView,
5 Platform,
6 StyleSheet,
7 Text,
8 TextInput,
9 TouchableOpacity,
10 View,
11 ActivityIndicator,
12} from 'react-native';
13import { SafeAreaView } from 'react-native-safe-area-context';
14import Markdown from 'react-native-markdown-display';
15import { postJSON } from '../api/client';
16import { colors } from '../theme/colors';
17
18interface ChatMessage {
19 id: string;
20 role: 'user' | 'assistant';
21 content: string;
22}
23
24interface AiChatResponse {
25 message: string;
26 chatId?: string;
27}
28
29const markdownStyles = {
30 body: {
31 color: colors.text,
32 fontSize: 14,
33 lineHeight: 22,
34 },
35 code_inline: {
36 backgroundColor: colors.bgTertiary,
37 color: colors.accentPurple,
38 fontFamily: 'monospace',
39 paddingHorizontal: 4,
40 borderRadius: 3,
41 fontSize: 13,
42 },
43 fence: {
44 backgroundColor: colors.bgTertiary,
45 borderRadius: 6,
46 padding: 12,
47 marginVertical: 8,
48 },
49 code_block: {
50 backgroundColor: colors.bgTertiary,
51 borderRadius: 6,
52 padding: 12,
53 fontFamily: 'monospace',
54 fontSize: 12,
55 },
56 link: {
57 color: colors.textLink,
58 },
59 blockquote: {
60 borderLeftColor: colors.accentPurple,
61 borderLeftWidth: 3,
62 paddingLeft: 10,
63 color: colors.textMuted,
64 },
65 heading1: {
66 color: colors.text,
67 fontWeight: '700' as const,
68 fontSize: 18,
69 },
70 heading2: {
71 color: colors.text,
72 fontWeight: '700' as const,
73 fontSize: 16,
74 },
75 heading3: {
76 color: colors.text,
77 fontWeight: '600' as const,
78 fontSize: 15,
79 },
80};
81
82function MessageBubble({ message }: { message: ChatMessage }): React.ReactElement {
83 const isUser = message.role === 'user';
84
85 return (
86 <View style={[styles.messageBubbleWrapper, isUser ? styles.userWrapper : styles.aiWrapper]}>
87 {!isUser && (
88 <View style={styles.aiAvatar}>
89 <Text style={styles.aiAvatarText}></Text>
90 </View>
91 )}
92 <View style={[styles.bubble, isUser ? styles.userBubble : styles.aiBubble]}>
93 {isUser ? (
94 <Text style={styles.userText}>{message.content}</Text>
95 ) : (
96 <Markdown style={markdownStyles}>{message.content}</Markdown>
97 )}
98 </View>
99 </View>
100 );
101}
102
103export function AskAIScreen(): React.ReactElement {
104 const [messages, setMessages] = useState<ChatMessage[]>([]);
105 const [input, setInput] = useState('');
106 const [isLoading, setIsLoading] = useState(false);
107 const [error, setError] = useState<string | null>(null);
108 const [chatId, setChatId] = useState<string | undefined>(undefined);
109 const listRef = useRef<FlatList<ChatMessage>>(null);
110 const idCounter = useRef(0);
111
112 const nextId = useCallback(() => {
113 idCounter.current += 1;
114 return String(idCounter.current);
115 }, []);
116
117 const handleSend = useCallback(async () => {
118 const text = input.trim();
119 if (!text || isLoading) return;
120
121 setError(null);
122 setInput('');
123
124 const userMessage: ChatMessage = {
125 id: nextId(),
126 role: 'user',
127 content: text,
128 };
129
130 setMessages((prev) => [...prev, userMessage]);
131 setIsLoading(true);
132
133 // Scroll to bottom
134 setTimeout(() => {
135 listRef.current?.scrollToEnd({ animated: true });
136 }, 100);
137
138 try {
139 const response = await postJSON<AiChatResponse>('/ask', {
140 message: text,
141 chatId,
142 });
143
144 if (response.chatId !== undefined) {
145 setChatId(response.chatId);
146 }
147
148 const aiMessage: ChatMessage = {
149 id: nextId(),
150 role: 'assistant',
151 content: response.message,
152 };
153
154 setMessages((prev) => [...prev, aiMessage]);
155
156 setTimeout(() => {
157 listRef.current?.scrollToEnd({ animated: true });
158 }, 100);
159 } catch (err) {
160 setError(err instanceof Error ? err.message : 'AI request failed');
161 // Put user message back in input on failure
162 setInput(text);
163 setMessages((prev) => prev.filter((m) => m.id !== userMessage.id));
164 } finally {
165 setIsLoading(false);
166 }
167 }, [input, isLoading, chatId, nextId]);
168
169 const handleClear = useCallback(() => {
170 setMessages([]);
171 setChatId(undefined);
172 setError(null);
173 }, []);
174
175 const renderMessage = useCallback(
176 ({ item }: { item: ChatMessage }) => <MessageBubble message={item} />,
177 [],
178 );
179
180 const keyExtractor = useCallback((item: ChatMessage) => item.id, []);
181
182 return (
183 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
184 {/* Header */}
185 <View style={styles.header}>
186 <View style={styles.headerLeft}>
187 <Text style={styles.headerIcon}></Text>
188 <Text style={styles.headerTitle}>Ask AI</Text>
189 </View>
190 {messages.length > 0 && (
191 <TouchableOpacity onPress={handleClear}>
192 <Text style={styles.clearText}>New chat</Text>
193 </TouchableOpacity>
194 )}
195 </View>
196
197 <KeyboardAvoidingView
198 style={styles.flex}
199 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
200 keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
201 >
202 {/* Messages */}
203 {messages.length === 0 ? (
204 <View style={styles.emptyState}>
205 <Text style={styles.emptyIcon}></Text>
206 <Text style={styles.emptyTitle}>Ask anything about your code</Text>
207 <Text style={styles.emptySubtitle}>
208 Ask about repositories, code reviews, gate failures, or anything Gluecron-related.
209 </Text>
210 <View style={styles.suggestions}>
211 {[
212 'Why did my gate check fail?',
213 'Review my latest pull request',
214 'Summarize recent changes',
215 ].map((s) => (
216 <TouchableOpacity
217 key={s}
218 style={styles.suggestion}
219 onPress={() => setInput(s)}
220 activeOpacity={0.8}
221 >
222 <Text style={styles.suggestionText}>{s}</Text>
223 </TouchableOpacity>
224 ))}
225 </View>
226 </View>
227 ) : (
228 <FlatList
229 ref={listRef}
230 data={messages}
231 keyExtractor={keyExtractor}
232 renderItem={renderMessage}
233 style={styles.messageList}
234 contentContainerStyle={styles.messageListContent}
235 onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })}
236 />
237 )}
238
239 {/* Loading indicator */}
240 {isLoading && (
241 <View style={styles.thinkingRow}>
242 <View style={styles.aiAvatar}>
243 <Text style={styles.aiAvatarText}></Text>
244 </View>
245 <View style={styles.thinkingBubble}>
246 <ActivityIndicator size="small" color={colors.accentPurple} />
247 <Text style={styles.thinkingText}>Thinking...</Text>
248 </View>
249 </View>
250 )}
251
252 {/* Error */}
253 {error !== null && (
254 <View style={styles.errorRow}>
255 <Text style={styles.errorText}>{error}</Text>
256 </View>
257 )}
258
259 {/* Input area */}
260 <View style={styles.inputArea}>
261 <TextInput
262 style={styles.input}
263 value={input}
264 onChangeText={setInput}
265 placeholder="Ask about your code..."
266 placeholderTextColor={colors.textMuted}
267 multiline
268 maxLength={4096}
269 returnKeyType="send"
270 onSubmitEditing={handleSend}
271 blurOnSubmit={false}
272 />
273 <TouchableOpacity
274 style={[styles.sendButton, (!input.trim() || isLoading) && styles.sendButtonDisabled]}
275 onPress={handleSend}
276 disabled={!input.trim() || isLoading}
277 activeOpacity={0.8}
278 >
279 <Text style={styles.sendIcon}></Text>
280 </TouchableOpacity>
281 </View>
282 </KeyboardAvoidingView>
283 </SafeAreaView>
284 );
285}
286
287const styles = StyleSheet.create({
288 safe: {
289 flex: 1,
290 backgroundColor: colors.bg,
291 },
292 flex: {
293 flex: 1,
294 },
295 header: {
296 flexDirection: 'row',
297 alignItems: 'center',
298 justifyContent: 'space-between',
299 paddingHorizontal: 16,
300 paddingVertical: 14,
301 backgroundColor: colors.bgSecondary,
302 borderBottomWidth: 1,
303 borderBottomColor: colors.border,
304 },
305 headerLeft: {
306 flexDirection: 'row',
307 alignItems: 'center',
308 gap: 8,
309 },
310 headerIcon: {
311 fontSize: 18,
312 color: colors.accentPurple,
313 },
314 headerTitle: {
315 fontSize: 18,
316 fontWeight: '700',
317 color: colors.accentPurple,
318 },
319 clearText: {
320 fontSize: 13,
321 color: colors.textLink,
322 },
323 emptyState: {
324 flex: 1,
325 alignItems: 'center',
326 justifyContent: 'center',
327 padding: 32,
328 gap: 12,
329 },
330 emptyIcon: {
331 fontSize: 48,
332 color: colors.accentPurple,
333 },
334 emptyTitle: {
335 fontSize: 18,
336 fontWeight: '700',
337 color: colors.text,
338 textAlign: 'center',
339 },
340 emptySubtitle: {
341 fontSize: 14,
342 color: colors.textMuted,
343 textAlign: 'center',
344 lineHeight: 20,
345 },
346 suggestions: {
347 width: '100%',
348 gap: 8,
349 marginTop: 8,
350 },
351 suggestion: {
352 paddingHorizontal: 16,
353 paddingVertical: 12,
354 backgroundColor: colors.bgSecondary,
355 borderRadius: 10,
356 borderWidth: 1,
357 borderColor: colors.border,
358 },
359 suggestionText: {
360 fontSize: 14,
361 color: colors.textLink,
362 textAlign: 'center',
363 },
364 messageList: {
365 flex: 1,
366 },
367 messageListContent: {
368 paddingVertical: 16,
369 paddingHorizontal: 12,
370 gap: 12,
371 },
372 messageBubbleWrapper: {
373 flexDirection: 'row',
374 alignItems: 'flex-end',
375 gap: 8,
376 },
377 userWrapper: {
378 justifyContent: 'flex-end',
379 },
380 aiWrapper: {
381 justifyContent: 'flex-start',
382 },
383 aiAvatar: {
384 width: 28,
385 height: 28,
386 borderRadius: 14,
387 backgroundColor: colors.accentPurple + '33',
388 borderWidth: 1,
389 borderColor: colors.accentPurple,
390 alignItems: 'center',
391 justifyContent: 'center',
392 flexShrink: 0,
393 },
394 aiAvatarText: {
395 fontSize: 13,
396 color: colors.accentPurple,
397 fontWeight: '700',
398 },
399 bubble: {
400 maxWidth: '80%',
401 borderRadius: 14,
402 paddingHorizontal: 14,
403 paddingVertical: 10,
404 },
405 userBubble: {
406 backgroundColor: colors.accentBlue,
407 borderBottomRightRadius: 4,
408 },
409 aiBubble: {
410 backgroundColor: colors.bgSecondary,
411 borderWidth: 1,
412 borderColor: colors.border,
413 borderBottomLeftRadius: 4,
414 },
415 userText: {
416 fontSize: 14,
417 color: colors.bg,
418 lineHeight: 20,
419 },
420 thinkingRow: {
421 flexDirection: 'row',
422 alignItems: 'center',
423 paddingHorizontal: 12,
424 paddingVertical: 8,
425 gap: 8,
426 },
427 thinkingBubble: {
428 flexDirection: 'row',
429 alignItems: 'center',
430 backgroundColor: colors.bgSecondary,
431 borderRadius: 14,
432 borderBottomLeftRadius: 4,
433 paddingHorizontal: 14,
434 paddingVertical: 10,
435 gap: 8,
436 borderWidth: 1,
437 borderColor: colors.border,
438 },
439 thinkingText: {
440 fontSize: 13,
441 color: colors.textMuted,
442 },
443 errorRow: {
444 marginHorizontal: 12,
445 marginBottom: 8,
446 padding: 10,
447 backgroundColor: colors.bg,
448 borderRadius: 8,
449 borderWidth: 1,
450 borderColor: colors.accentRed,
451 },
452 errorText: {
453 fontSize: 13,
454 color: colors.accentRed,
455 },
456 inputArea: {
457 flexDirection: 'row',
458 alignItems: 'flex-end',
459 padding: 12,
460 backgroundColor: colors.bgSecondary,
461 borderTopWidth: 1,
462 borderTopColor: colors.border,
463 gap: 10,
464 },
465 input: {
466 flex: 1,
467 backgroundColor: colors.bg,
468 borderWidth: 1,
469 borderColor: colors.border,
470 borderRadius: 20,
471 paddingHorizontal: 16,
472 paddingVertical: 10,
473 fontSize: 14,
474 color: colors.text,
475 maxHeight: 120,
476 minHeight: 44,
477 },
478 sendButton: {
479 width: 44,
480 height: 44,
481 borderRadius: 22,
482 backgroundColor: colors.accentPurple,
483 alignItems: 'center',
484 justifyContent: 'center',
485 },
486 sendButtonDisabled: {
487 opacity: 0.4,
488 },
489 sendIcon: {
490 fontSize: 20,
491 color: colors.text,
492 fontWeight: '700',
493 },
494});
Addedmobile/src/screens/CommitsScreen.tsx+92−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import { FlatList, StyleSheet, Text, View } from 'react-native';
3import { SafeAreaView } from 'react-native-safe-area-context';
4import type { NativeStackScreenProps } from '@react-navigation/native-stack';
5import { useCommits } from '../hooks/useRepo';
6import { CommitRow } from '../components/CommitRow';
7import { LoadingSpinner } from '../components/LoadingSpinner';
8import { ErrorBanner } from '../components/ErrorBanner';
9import { colors } from '../theme/colors';
10import type { RepoStackParamList } from '../navigation/AppNavigator';
11import type { CommitEntry } from '../api/repos';
12
13type Props = NativeStackScreenProps<RepoStackParamList, 'Commits'>;
14
15export function CommitsScreen({ route }: Props): React.ReactElement {
16 const { owner, repo, branch } = route.params;
17 const { commits, isLoading, error, loadMore, hasMore } = useCommits(
18 owner,
19 repo,
20 branch ?? 'HEAD',
21 );
22
23 const renderCommit = useCallback(
24 ({ item }: { item: CommitEntry }) => <CommitRow commit={item} />,
25 [],
26 );
27
28 const keyExtractor = useCallback((item: CommitEntry) => item.sha, []);
29
30 const renderFooter = useCallback(() => {
31 if (!hasMore) return null;
32 return <LoadingSpinner size="small" />;
33 }, [hasMore]);
34
35 if (isLoading && commits.length === 0) return <LoadingSpinner fullScreen />;
36
37 return (
38 <SafeAreaView style={styles.safe} edges={['bottom']}>
39 {error !== null && <ErrorBanner message={error} />}
40 <FlatList
41 data={commits}
42 keyExtractor={keyExtractor}
43 renderItem={renderCommit}
44 onEndReached={loadMore}
45 onEndReachedThreshold={0.3}
46 ListFooterComponent={renderFooter}
47 ListEmptyComponent={
48 <View style={styles.emptyState}>
49 <Text style={styles.emptyText}>No commits found</Text>
50 </View>
51 }
52 ListHeaderComponent={
53 <View style={styles.header}>
54 <Text style={styles.headerText}>
55 {branch ?? 'HEAD'} — commit history
56 </Text>
57 </View>
58 }
59 style={styles.list}
60 />
61 </SafeAreaView>
62 );
63}
64
65const styles = StyleSheet.create({
66 safe: {
67 flex: 1,
68 backgroundColor: colors.bg,
69 },
70 list: {
71 flex: 1,
72 },
73 header: {
74 padding: 12,
75 backgroundColor: colors.bgTertiary,
76 borderBottomWidth: 1,
77 borderBottomColor: colors.border,
78 },
79 headerText: {
80 fontSize: 12,
81 fontFamily: 'monospace',
82 color: colors.textMuted,
83 },
84 emptyState: {
85 padding: 40,
86 alignItems: 'center',
87 },
88 emptyText: {
89 fontSize: 14,
90 color: colors.textMuted,
91 },
92});
Addedmobile/src/screens/DashboardScreen.tsx+313−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 ScrollView,
5 StyleSheet,
6 Text,
7 TouchableOpacity,
8 View,
9} from 'react-native';
10import { StatusBar } from 'expo-status-bar';
11import { SafeAreaView } from 'react-native-safe-area-context';
12import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
13import { useAuthStore } from '../store/authStore';
14import { listUserRepos } from '../api/repos';
15import { getNotificationCounts } from '../api/notifications';
16import type { Repository } from '../api/repos';
17import { RepoCard } from '../components/RepoCard';
18import { LoadingSpinner } from '../components/LoadingSpinner';
19import { ErrorBanner } from '../components/ErrorBanner';
20import { colors } from '../theme/colors';
21import type { MainTabParamList } from '../navigation/AppNavigator';
22
23type Props = BottomTabScreenProps<MainTabParamList, 'Dashboard'>;
24
25export function DashboardScreen({ navigation }: Props): React.ReactElement {
26 const user = useAuthStore((s) => s.user);
27 const [repos, setRepos] = useState<Repository[]>([]);
28 const [notifCount, setNotifCount] = useState(0);
29 const [isLoading, setIsLoading] = useState(true);
30 const [error, setError] = useState<string | null>(null);
31 const [tick, setTick] = useState(0);
32
33 const greenCount = repos.filter((r) => r.openIssueCount === 0).length;
34
35 useEffect(() => {
36 if (!user) return;
37 let cancelled = false;
38
39 setIsLoading(true);
40 setError(null);
41
42 Promise.all([
43 listUserRepos(user.username),
44 getNotificationCounts().catch(() => ({ total: 0, unread: 0 })),
45 ])
46 .then(([repoList, counts]) => {
47 if (!cancelled) {
48 setRepos(repoList.slice(0, 10));
49 setNotifCount(counts.unread);
50 }
51 })
52 .catch((err) => {
53 if (!cancelled) {
54 setError(err instanceof Error ? err.message : 'Failed to load dashboard');
55 }
56 })
57 .finally(() => {
58 if (!cancelled) setIsLoading(false);
59 });
60
61 return () => {
62 cancelled = true;
63 };
64 }, [user, tick]);
65
66 const retry = useCallback(() => setTick((t) => t + 1), []);
67
68 const handleRepoPress = useCallback(
69 (repo: Repository) => {
70 (navigation as any).navigate('Repos', {
71 screen: 'Repo',
72 params: { owner: repo.ownerUsername, repo: repo.name },
73 });
74 },
75 [navigation],
76 );
77
78 const navigateNotifications = useCallback(() => {
79 (navigation as any).navigate('Notifications');
80 }, [navigation]);
81
82 const navigateAskAI = useCallback(() => {
83 (navigation as any).navigate('AskAI');
84 }, [navigation]);
85
86 const renderRepo = useCallback(
87 ({ item }: { item: Repository }) => (
88 <RepoCard repo={item} onPress={handleRepoPress} />
89 ),
90 [handleRepoPress],
91 );
92
93 const keyExtractor = useCallback((item: Repository) => String(item.id), []);
94
95 if (isLoading) return <LoadingSpinner fullScreen />;
96
97 return (
98 <SafeAreaView style={styles.safe} edges={['top']}>
99 <StatusBar style="light" />
100
101 {/* Header */}
102 <View style={styles.header}>
103 <View>
104 <Text style={styles.greeting}>
105 Hello, {user?.username ?? 'there'} 👋
106 </Text>
107 <Text style={styles.subtitle}>Your Gluecron dashboard</Text>
108 </View>
109 <TouchableOpacity style={styles.notifButton} onPress={navigateNotifications}>
110 <Text style={styles.notifIcon}>🔔</Text>
111 {notifCount > 0 && (
112 <View style={styles.badge}>
113 <Text style={styles.badgeText}>
114 {notifCount > 99 ? '99+' : String(notifCount)}
115 </Text>
116 </View>
117 )}
118 </TouchableOpacity>
119 </View>
120
121 {error !== null && <ErrorBanner message={error} onRetry={retry} />}
122
123 <ScrollView style={styles.flex} showsVerticalScrollIndicator={false}>
124 {/* Stats row */}
125 <View style={styles.statsRow}>
126 <View style={styles.statCard}>
127 <Text style={styles.statValue}>{repos.length}</Text>
128 <Text style={styles.statLabel}>Repositories</Text>
129 </View>
130 <View style={styles.statCard}>
131 <Text style={[styles.statValue, { color: colors.accent }]}>{greenCount}</Text>
132 <Text style={styles.statLabel}>Gates Green</Text>
133 </View>
134 <View style={styles.statCard}>
135 <Text style={[styles.statValue, { color: colors.accentRed }]}>
136 {repos.length - greenCount}
137 </Text>
138 <Text style={styles.statLabel}>Need Attention</Text>
139 </View>
140 </View>
141
142 {/* Recent repos */}
143 <View style={styles.sectionHeader}>
144 <Text style={styles.sectionTitle}>Recent Repositories</Text>
145 <TouchableOpacity
146 onPress={() => (navigation as any).navigate('Repos')}
147 >
148 <Text style={styles.seeAll}>See all</Text>
149 </TouchableOpacity>
150 </View>
151
152 <FlatList
153 data={repos}
154 keyExtractor={keyExtractor}
155 renderItem={renderRepo}
156 scrollEnabled={false}
157 ListEmptyComponent={
158 <View style={styles.emptyState}>
159 <Text style={styles.emptyText}>No repositories yet</Text>
160 </View>
161 }
162 />
163
164 {/* Spacer for FAB */}
165 <View style={styles.fabSpacer} />
166 </ScrollView>
167
168 {/* Ask AI FAB */}
169 <TouchableOpacity style={styles.fab} onPress={navigateAskAI} activeOpacity={0.85}>
170 <Text style={styles.fabIcon}></Text>
171 <Text style={styles.fabText}>Ask AI</Text>
172 </TouchableOpacity>
173 </SafeAreaView>
174 );
175}
176
177const styles = StyleSheet.create({
178 safe: {
179 flex: 1,
180 backgroundColor: colors.bg,
181 },
182 flex: {
183 flex: 1,
184 },
185 header: {
186 flexDirection: 'row',
187 alignItems: 'center',
188 justifyContent: 'space-between',
189 paddingHorizontal: 16,
190 paddingVertical: 16,
191 borderBottomWidth: 1,
192 borderBottomColor: colors.border,
193 backgroundColor: colors.bgSecondary,
194 },
195 greeting: {
196 fontSize: 18,
197 fontWeight: '700',
198 color: colors.text,
199 },
200 subtitle: {
201 fontSize: 13,
202 color: colors.textMuted,
203 marginTop: 2,
204 },
205 notifButton: {
206 width: 44,
207 height: 44,
208 alignItems: 'center',
209 justifyContent: 'center',
210 },
211 notifIcon: {
212 fontSize: 22,
213 },
214 badge: {
215 position: 'absolute',
216 top: 4,
217 right: 4,
218 minWidth: 18,
219 height: 18,
220 borderRadius: 9,
221 backgroundColor: colors.accentRed,
222 alignItems: 'center',
223 justifyContent: 'center',
224 paddingHorizontal: 4,
225 },
226 badgeText: {
227 fontSize: 10,
228 fontWeight: '700',
229 color: colors.text,
230 },
231 statsRow: {
232 flexDirection: 'row',
233 padding: 16,
234 gap: 12,
235 },
236 statCard: {
237 flex: 1,
238 backgroundColor: colors.bgSecondary,
239 borderRadius: 10,
240 borderWidth: 1,
241 borderColor: colors.border,
242 padding: 14,
243 alignItems: 'center',
244 gap: 4,
245 },
246 statValue: {
247 fontSize: 24,
248 fontWeight: '700',
249 color: colors.text,
250 },
251 statLabel: {
252 fontSize: 11,
253 color: colors.textMuted,
254 textAlign: 'center',
255 },
256 sectionHeader: {
257 flexDirection: 'row',
258 alignItems: 'center',
259 justifyContent: 'space-between',
260 paddingHorizontal: 16,
261 paddingBottom: 8,
262 paddingTop: 4,
263 },
264 sectionTitle: {
265 fontSize: 14,
266 fontWeight: '600',
267 color: colors.textMuted,
268 textTransform: 'uppercase',
269 letterSpacing: 0.5,
270 },
271 seeAll: {
272 fontSize: 13,
273 color: colors.textLink,
274 },
275 emptyState: {
276 padding: 32,
277 alignItems: 'center',
278 },
279 emptyText: {
280 fontSize: 14,
281 color: colors.textMuted,
282 },
283 fabSpacer: {
284 height: 80,
285 },
286 fab: {
287 position: 'absolute',
288 bottom: 24,
289 right: 20,
290 flexDirection: 'row',
291 alignItems: 'center',
292 backgroundColor: colors.accentPurple,
293 borderRadius: 28,
294 paddingHorizontal: 20,
295 paddingVertical: 14,
296 gap: 8,
297 shadowColor: colors.accentPurple,
298 shadowOpacity: 0.4,
299 shadowRadius: 12,
300 shadowOffset: { width: 0, height: 4 },
301 elevation: 8,
302 },
303 fabIcon: {
304 fontSize: 16,
305 color: colors.bg,
306 fontWeight: '700',
307 },
308 fabText: {
309 fontSize: 15,
310 fontWeight: '700',
311 color: colors.bg,
312 },
313});
Addedmobile/src/screens/FileViewerScreen.tsx+251−0View fileUnifiedSplit
1import React, { useEffect, useState, useCallback } from 'react';
2import {
3 FlatList,
4 ScrollView,
5 StyleSheet,
6 Text,
7 TouchableOpacity,
8 View,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import type { NativeStackScreenProps } from '@react-navigation/native-stack';
12import { getFileContent, getTree } from '../api/repos';
13import type { TreeEntry } from '../api/repos';
14import { LoadingSpinner } from '../components/LoadingSpinner';
15import { ErrorBanner } from '../components/ErrorBanner';
16import { colors } from '../theme/colors';
17import type { RepoStackParamList } from '../navigation/AppNavigator';
18
19type Props = NativeStackScreenProps<RepoStackParamList, 'FileViewer'>;
20
21function isTextFile(path: string): boolean {
22 const ext = path.split('.').pop()?.toLowerCase() ?? '';
23 const textExts = new Set([
24 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
25 'json', 'yaml', 'yml', 'toml', 'env',
26 'md', 'mdx', 'txt', 'rst',
27 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift',
28 'c', 'cpp', 'h', 'hpp', 'cs',
29 'html', 'htm', 'css', 'scss', 'sass', 'less',
30 'sh', 'bash', 'zsh', 'fish',
31 'sql', 'graphql', 'proto',
32 'xml', 'svg', 'gitignore', 'dockerignore',
33 ]);
34 return textExts.has(ext);
35}
36
37function isImageFile(path: string): boolean {
38 const ext = path.split('.').pop()?.toLowerCase() ?? '';
39 return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'].includes(ext);
40}
41
42export function FileViewerScreen({ route, navigation }: Props): React.ReactElement {
43 const { owner, repo, ref, path } = route.params;
44
45 const [content, setContent] = useState<string | null>(null);
46 const [dirEntries, setDirEntries] = useState<TreeEntry[] | null>(null);
47 const [isLoading, setIsLoading] = useState(true);
48 const [error, setError] = useState<string | null>(null);
49 const [mode, setMode] = useState<'file' | 'dir'>('file');
50
51 useEffect(() => {
52 let cancelled = false;
53 setIsLoading(true);
54 setError(null);
55 setContent(null);
56 setDirEntries(null);
57
58 // Try directory first, then file
59 getTree(owner, repo, ref, path)
60 .then((entries) => {
61 if (!cancelled) {
62 if (entries.length > 0 && entries[0].path !== path) {
63 // It's a directory listing
64 setMode('dir');
65 setDirEntries(entries);
66 } else {
67 // It's a file — fetch content
68 setMode('file');
69 return getFileContent(owner, repo, ref, path).then((c) => {
70 if (!cancelled) setContent(c);
71 });
72 }
73 }
74 })
75 .catch(() => {
76 // Fallback: treat as file
77 getFileContent(owner, repo, ref, path)
78 .then((c) => {
79 if (!cancelled) {
80 setMode('file');
81 setContent(c);
82 }
83 })
84 .catch((err) => {
85 if (!cancelled) {
86 setError(err instanceof Error ? err.message : 'Failed to load file');
87 }
88 });
89 })
90 .finally(() => {
91 if (!cancelled) setIsLoading(false);
92 });
93
94 return () => {
95 cancelled = true;
96 };
97 }, [owner, repo, ref, path]);
98
99 const retry = useCallback(() => {
100 setIsLoading(true);
101 setError(null);
102 }, []);
103
104 const handleEntryPress = useCallback(
105 (entry: TreeEntry) => {
106 navigation.push('FileViewer', {
107 owner,
108 repo,
109 ref,
110 path: entry.path,
111 });
112 },
113 [navigation, owner, repo, ref],
114 );
115
116 const renderEntry = useCallback(
117 ({ item }: { item: TreeEntry }) => (
118 <TouchableOpacity
119 style={styles.fileRow}
120 onPress={() => handleEntryPress(item)}
121 activeOpacity={0.75}
122 >
123 <Text style={styles.fileIcon}>{item.type === 'tree' ? '📁' : '📄'}</Text>
124 <Text style={styles.fileName} numberOfLines={1}>
125 {item.name}
126 </Text>
127 {item.type === 'tree' && <Text style={styles.chevron}></Text>}
128 </TouchableOpacity>
129 ),
130 [handleEntryPress],
131 );
132
133 const keyExtractor = useCallback((item: TreeEntry) => item.path, []);
134
135 if (isLoading) return <LoadingSpinner fullScreen />;
136
137 return (
138 <SafeAreaView style={styles.safe} edges={['bottom']}>
139 {/* Breadcrumb */}
140 <View style={styles.breadcrumb}>
141 <Text style={styles.breadcrumbText} numberOfLines={1}>
142 {owner}/{repo}/{path}
143 </Text>
144 </View>
145
146 {error !== null && <ErrorBanner message={error} onRetry={retry} />}
147
148 {mode === 'dir' && dirEntries !== null && (
149 <FlatList
150 data={dirEntries}
151 keyExtractor={keyExtractor}
152 renderItem={renderEntry}
153 style={styles.dirList}
154 />
155 )}
156
157 {mode === 'file' && content !== null && (
158 <ScrollView style={styles.fileScroll} horizontal={false}>
159 <ScrollView horizontal showsHorizontalScrollIndicator>
160 {isImageFile(path) ? (
161 <View style={styles.unsupported}>
162 <Text style={styles.unsupportedText}>Image preview not available</Text>
163 </View>
164 ) : isTextFile(path) ? (
165 <View style={styles.codeContainer}>
166 <Text style={styles.codeText} selectable>
167 {content}
168 </Text>
169 </View>
170 ) : (
171 <View style={styles.unsupported}>
172 <Text style={styles.unsupportedText}>
173 Binary file — {path.split('.').pop()?.toUpperCase()} cannot be previewed
174 </Text>
175 </View>
176 )}
177 </ScrollView>
178 </ScrollView>
179 )}
180 </SafeAreaView>
181 );
182}
183
184const styles = StyleSheet.create({
185 safe: {
186 flex: 1,
187 backgroundColor: colors.bg,
188 },
189 breadcrumb: {
190 paddingHorizontal: 14,
191 paddingVertical: 10,
192 backgroundColor: colors.bgSecondary,
193 borderBottomWidth: 1,
194 borderBottomColor: colors.border,
195 },
196 breadcrumbText: {
197 fontSize: 12,
198 fontFamily: 'monospace',
199 color: colors.textMuted,
200 },
201 dirList: {
202 flex: 1,
203 backgroundColor: colors.bgSecondary,
204 },
205 fileRow: {
206 flexDirection: 'row',
207 alignItems: 'center',
208 paddingVertical: 10,
209 paddingHorizontal: 16,
210 borderBottomWidth: 1,
211 borderBottomColor: colors.border,
212 gap: 10,
213 },
214 fileIcon: {
215 fontSize: 15,
216 },
217 fileName: {
218 flex: 1,
219 fontSize: 14,
220 color: colors.textLink,
221 },
222 chevron: {
223 fontSize: 18,
224 color: colors.textMuted,
225 },
226 fileScroll: {
227 flex: 1,
228 backgroundColor: colors.bg,
229 },
230 codeContainer: {
231 padding: 16,
232 minWidth: '100%',
233 },
234 codeText: {
235 fontSize: 12,
236 fontFamily: 'monospace',
237 color: colors.text,
238 lineHeight: 18,
239 },
240 unsupported: {
241 flex: 1,
242 padding: 32,
243 alignItems: 'center',
244 justifyContent: 'center',
245 },
246 unsupportedText: {
247 fontSize: 14,
248 color: colors.textMuted,
249 textAlign: 'center',
250 },
251});
Addedmobile/src/screens/GateStatusScreen.tsx+352−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import {
3 FlatList,
4 ScrollView,
5 StyleSheet,
6 Text,
7 TouchableOpacity,
8 View,
9 ActivityIndicator,
10} from 'react-native';
11import { SafeAreaView } from 'react-native-safe-area-context';
12import type { NativeStackScreenProps } from '@react-navigation/native-stack';
13import { useGates } from '../hooks/useGates';
14import { GateStatusBadge } from '../components/GateStatusBadge';
15import { LoadingSpinner } from '../components/LoadingSpinner';
16import { ErrorBanner } from '../components/ErrorBanner';
17import { colors } from '../theme/colors';
18import type { RepoStackParamList } from '../navigation/AppNavigator';
19import type { GateRun } from '../api/gates';
20
21type Props = NativeStackScreenProps<RepoStackParamList, 'GateStatus'>;
22
23function formatRelative(dateStr: string): string {
24 const diff = Date.now() - new Date(dateStr).getTime();
25 const mins = Math.floor(diff / 60000);
26 if (mins < 1) return 'just now';
27 if (mins < 60) return `${mins}m ago`;
28 const hrs = Math.floor(mins / 60);
29 if (hrs < 24) return `${hrs}h ago`;
30 const days = Math.floor(hrs / 24);
31 if (days < 30) return `${days}d ago`;
32 const months = Math.floor(days / 30);
33 if (months < 12) return `${months}mo ago`;
34 return `${Math.floor(months / 12)}y ago`;
35}
36
37function duration(start: string, end: string | null): string {
38 if (!end) return 'running...';
39 const ms = new Date(end).getTime() - new Date(start).getTime();
40 const secs = Math.round(ms / 1000);
41 if (secs < 60) return `${secs}s`;
42 return `${Math.floor(secs / 60)}m ${secs % 60}s`;
43}
44
45function GateRunCard({ run }: { run: GateRun }): React.ReactElement {
46 const [expanded, setExpanded] = React.useState(false);
47
48 return (
49 <View style={styles.runCard}>
50 <View style={styles.runHeader}>
51 <GateStatusBadge status={run.status} aiRepaired={run.aiRepaired} size="small" />
52 <View style={styles.runMeta}>
53 <Text style={styles.runBranch}>{run.branch}</Text>
54 <Text style={styles.runSha}>{run.commitSha.slice(0, 7)}</Text>
55 </View>
56 <Text style={styles.runTime}>{formatRelative(run.createdAt)}</Text>
57 </View>
58
59 <View style={styles.runDetails}>
60 <Text style={styles.runDetailText}>
61 Duration: {duration(run.startedAt, run.completedAt)}
62 </Text>
63 {run.triggeredBy !== null && (
64 <Text style={styles.runDetailText}>Triggered by {run.triggeredBy}</Text>
65 )}
66 {run.aiRepaired && run.repairedCommitSha !== null && (
67 <View style={styles.repairedRow}>
68 <Text style={styles.sparkle}></Text>
69 <Text style={styles.repairedText}>
70 AI auto-repaired → {run.repairedCommitSha.slice(0, 7)}
71 </Text>
72 </View>
73 )}
74 </View>
75
76 {run.output !== null && run.output.length > 0 && (
77 <TouchableOpacity
78 style={styles.outputToggle}
79 onPress={() => setExpanded((v) => !v)}
80 activeOpacity={0.8}
81 >
82 <Text style={styles.outputToggleText}>
83 {expanded ? '▾ Hide output' : '▸ Show output'}
84 </Text>
85 </TouchableOpacity>
86 )}
87
88 {expanded && run.output !== null && (
89 <ScrollView horizontal showsHorizontalScrollIndicator={false}>
90 <View style={styles.outputBox}>
91 <Text style={styles.outputText} selectable>
92 {run.output}
93 </Text>
94 </View>
95 </ScrollView>
96 )}
97 </View>
98 );
99}
100
101export function GateStatusScreen({ route }: Props): React.ReactElement {
102 const { owner, repo } = route.params;
103 const { runs, isLoading, error, isTriggering, triggerRun, loadMore, hasMore, refresh } =
104 useGates(owner, repo);
105
106 const renderRun = useCallback(
107 ({ item }: { item: GateRun }) => <GateRunCard run={item} />,
108 [],
109 );
110
111 const keyExtractor = useCallback((item: GateRun) => item.id, []);
112
113 const renderFooter = useCallback(
114 () => (hasMore ? <LoadingSpinner size="small" /> : null),
115 [hasMore],
116 );
117
118 // Summary stats
119 const passed = runs.filter((r) => r.status === 'passed').length;
120 const failed = runs.filter((r) => r.status === 'failed').length;
121 const repaired = runs.filter((r) => r.aiRepaired).length;
122
123 if (isLoading && runs.length === 0) return <LoadingSpinner fullScreen />;
124
125 return (
126 <SafeAreaView style={styles.safe} edges={['bottom']}>
127 {error !== null && <ErrorBanner message={error} onRetry={refresh} />}
128
129 <FlatList
130 data={runs}
131 keyExtractor={keyExtractor}
132 renderItem={renderRun}
133 onEndReached={loadMore}
134 onEndReachedThreshold={0.3}
135 ListFooterComponent={renderFooter}
136 contentContainerStyle={styles.listContent}
137 ListHeaderComponent={
138 <View>
139 {/* Stats banner */}
140 <View style={styles.statsBanner}>
141 <View style={styles.statItem}>
142 <Text style={[styles.statValue, { color: colors.accent }]}>{passed}</Text>
143 <Text style={styles.statLabel}>Passed</Text>
144 </View>
145 <View style={[styles.statItem, styles.statItemBorder]}>
146 <Text style={[styles.statValue, { color: colors.accentRed }]}>{failed}</Text>
147 <Text style={styles.statLabel}>Failed</Text>
148 </View>
149 <View style={styles.statItem}>
150 <Text style={[styles.statValue, { color: colors.accentPurple }]}>{repaired}</Text>
151 <Text style={styles.statLabel}>AI Repaired</Text>
152 </View>
153 </View>
154
155 {/* Trigger button */}
156 <View style={styles.triggerSection}>
157 <TouchableOpacity
158 style={[styles.triggerButton, isTriggering && styles.triggerButtonDisabled]}
159 onPress={triggerRun}
160 disabled={isTriggering}
161 activeOpacity={0.8}
162 >
163 {isTriggering ? (
164 <ActivityIndicator size="small" color={colors.text} />
165 ) : (
166 <Text style={styles.triggerText}>⚡ Run Gate Check</Text>
167 )}
168 </TouchableOpacity>
169 </View>
170
171 {runs.length > 0 && (
172 <View style={styles.sectionHeader}>
173 <Text style={styles.sectionTitle}>Gate Run History</Text>
174 </View>
175 )}
176 </View>
177 }
178 ListEmptyComponent={
179 <View style={styles.emptyState}>
180 <Text style={styles.emptyText}>No gate runs yet</Text>
181 <Text style={styles.emptySubText}>
182 Gate checks run automatically on every push to the default branch.
183 </Text>
184 </View>
185 }
186 />
187 </SafeAreaView>
188 );
189}
190
191const styles = StyleSheet.create({
192 safe: {
193 flex: 1,
194 backgroundColor: colors.bg,
195 },
196 listContent: {
197 paddingBottom: 24,
198 },
199 statsBanner: {
200 flexDirection: 'row',
201 backgroundColor: colors.bgSecondary,
202 borderBottomWidth: 1,
203 borderBottomColor: colors.border,
204 },
205 statItem: {
206 flex: 1,
207 paddingVertical: 16,
208 alignItems: 'center',
209 gap: 4,
210 },
211 statItemBorder: {
212 borderLeftWidth: 1,
213 borderRightWidth: 1,
214 borderColor: colors.border,
215 },
216 statValue: {
217 fontSize: 24,
218 fontWeight: '700',
219 },
220 statLabel: {
221 fontSize: 11,
222 color: colors.textMuted,
223 },
224 triggerSection: {
225 padding: 16,
226 borderBottomWidth: 1,
227 borderBottomColor: colors.border,
228 },
229 triggerButton: {
230 backgroundColor: colors.bgTertiary,
231 borderRadius: 8,
232 paddingVertical: 12,
233 alignItems: 'center',
234 borderWidth: 1,
235 borderColor: colors.accentBlue,
236 minHeight: 44,
237 justifyContent: 'center',
238 },
239 triggerButtonDisabled: {
240 opacity: 0.5,
241 },
242 triggerText: {
243 fontSize: 14,
244 fontWeight: '600',
245 color: colors.accentBlue,
246 },
247 sectionHeader: {
248 paddingHorizontal: 16,
249 paddingVertical: 10,
250 },
251 sectionTitle: {
252 fontSize: 12,
253 fontWeight: '600',
254 color: colors.textMuted,
255 textTransform: 'uppercase',
256 letterSpacing: 0.5,
257 },
258 runCard: {
259 backgroundColor: colors.bgSecondary,
260 borderBottomWidth: 1,
261 borderBottomColor: colors.border,
262 marginHorizontal: 16,
263 marginBottom: 10,
264 borderRadius: 8,
265 borderWidth: 1,
266 overflow: 'hidden',
267 },
268 runHeader: {
269 flexDirection: 'row',
270 alignItems: 'center',
271 padding: 12,
272 gap: 10,
273 },
274 runMeta: {
275 flex: 1,
276 gap: 2,
277 },
278 runBranch: {
279 fontSize: 13,
280 fontFamily: 'monospace',
281 color: colors.text,
282 },
283 runSha: {
284 fontSize: 11,
285 fontFamily: 'monospace',
286 color: colors.textMuted,
287 },
288 runTime: {
289 fontSize: 12,
290 color: colors.textMuted,
291 },
292 runDetails: {
293 paddingHorizontal: 12,
294 paddingBottom: 10,
295 gap: 3,
296 },
297 runDetailText: {
298 fontSize: 12,
299 color: colors.textMuted,
300 },
301 repairedRow: {
302 flexDirection: 'row',
303 alignItems: 'center',
304 gap: 5,
305 marginTop: 4,
306 },
307 sparkle: {
308 fontSize: 12,
309 color: colors.accentPurple,
310 },
311 repairedText: {
312 fontSize: 12,
313 color: colors.accentPurple,
314 fontFamily: 'monospace',
315 },
316 outputToggle: {
317 paddingHorizontal: 12,
318 paddingVertical: 8,
319 borderTopWidth: 1,
320 borderTopColor: colors.border,
321 },
322 outputToggleText: {
323 fontSize: 12,
324 color: colors.textLink,
325 },
326 outputBox: {
327 padding: 12,
328 backgroundColor: colors.bg,
329 },
330 outputText: {
331 fontSize: 11,
332 fontFamily: 'monospace',
333 color: colors.text,
334 lineHeight: 16,
335 },
336 emptyState: {
337 padding: 40,
338 alignItems: 'center',
339 gap: 8,
340 },
341 emptyText: {
342 fontSize: 16,
343 fontWeight: '600',
344 color: colors.textMuted,
345 },
346 emptySubText: {
347 fontSize: 13,
348 color: colors.textMuted,
349 textAlign: 'center',
350 lineHeight: 18,
351 },
352});
Addedmobile/src/screens/IssueDetailScreen.tsx+419−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 KeyboardAvoidingView,
5 Platform,
6 ScrollView,
7 StyleSheet,
8 Text,
9 TextInput,
10 TouchableOpacity,
11 View,
12} from 'react-native';
13import { SafeAreaView } from 'react-native-safe-area-context';
14import type { NativeStackScreenProps } from '@react-navigation/native-stack';
15import { getIssue, getIssueComments, createIssueComment, closeIssue, reopenIssue } from '../api/issues';
16import type { Issue, IssueComment } from '../api/issues';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorBanner } from '../components/ErrorBanner';
19import { colors } from '../theme/colors';
20import type { RepoStackParamList } from '../navigation/AppNavigator';
21import { useAuthStore } from '../store/authStore';
22
23type Props = NativeStackScreenProps<RepoStackParamList, 'IssueDetail'>;
24
25function formatRelative(dateStr: string): string {
26 const diff = Date.now() - new Date(dateStr).getTime();
27 const mins = Math.floor(diff / 60000);
28 if (mins < 1) return 'just now';
29 if (mins < 60) return `${mins}m ago`;
30 const hrs = Math.floor(mins / 60);
31 if (hrs < 24) return `${hrs}h ago`;
32 const days = Math.floor(hrs / 24);
33 if (days < 30) return `${days}d ago`;
34 const months = Math.floor(days / 30);
35 if (months < 12) return `${months}mo ago`;
36 return `${Math.floor(months / 12)}y ago`;
37}
38
39function CommentCard({ comment }: { comment: IssueComment }): React.ReactElement {
40 return (
41 <View style={styles.commentCard}>
42 <View style={styles.commentHeader}>
43 <Text style={styles.commentAuthor}>{comment.authorUsername}</Text>
44 <Text style={styles.commentTime}>{formatRelative(comment.createdAt)}</Text>
45 </View>
46 <Text style={styles.commentBody}>{comment.body}</Text>
47 </View>
48 );
49}
50
51export function IssueDetailScreen({ route }: Props): React.ReactElement {
52 const { owner, repo, number } = route.params;
53 const currentUser = useAuthStore((s) => s.user);
54
55 const [issue, setIssue] = useState<Issue | null>(null);
56 const [comments, setComments] = useState<IssueComment[]>([]);
57 const [isLoading, setIsLoading] = useState(true);
58 const [error, setError] = useState<string | null>(null);
59 const [commentText, setCommentText] = useState('');
60 const [isSubmitting, setIsSubmitting] = useState(false);
61 const [tick, setTick] = useState(0);
62
63 useEffect(() => {
64 let cancelled = false;
65 setIsLoading(true);
66 setError(null);
67
68 Promise.all([
69 getIssue(owner, repo, number),
70 getIssueComments(owner, repo, number),
71 ])
72 .then(([issueData, commentData]) => {
73 if (!cancelled) {
74 setIssue(issueData);
75 setComments(commentData);
76 }
77 })
78 .catch((err) => {
79 if (!cancelled) {
80 setError(err instanceof Error ? err.message : 'Failed to load issue');
81 }
82 })
83 .finally(() => {
84 if (!cancelled) setIsLoading(false);
85 });
86
87 return () => {
88 cancelled = true;
89 };
90 }, [owner, repo, number, tick]);
91
92 const handleSubmitComment = useCallback(async () => {
93 const body = commentText.trim();
94 if (!body || isSubmitting) return;
95 setIsSubmitting(true);
96 try {
97 const newComment = await createIssueComment(owner, repo, number, { body });
98 setComments((prev) => [...prev, newComment]);
99 setCommentText('');
100 } catch (err) {
101 setError(err instanceof Error ? err.message : 'Failed to post comment');
102 } finally {
103 setIsSubmitting(false);
104 }
105 }, [owner, repo, number, commentText, isSubmitting]);
106
107 const handleToggleState = useCallback(async () => {
108 if (!issue || isSubmitting) return;
109 setIsSubmitting(true);
110 try {
111 const updated = issue.state === 'open'
112 ? await closeIssue(owner, repo, number)
113 : await reopenIssue(owner, repo, number);
114 setIssue(updated);
115 } catch (err) {
116 setError(err instanceof Error ? err.message : 'Failed to update issue');
117 } finally {
118 setIsSubmitting(false);
119 }
120 }, [issue, owner, repo, number, isSubmitting]);
121
122 const renderComment = useCallback(
123 ({ item }: { item: IssueComment }) => <CommentCard comment={item} />,
124 [],
125 );
126
127 const keyExtractor = useCallback((item: IssueComment) => String(item.id), []);
128
129 if (isLoading) return <LoadingSpinner fullScreen />;
130
131 if (issue === null) {
132 return (
133 <SafeAreaView style={styles.safe} edges={['bottom']}>
134 <ErrorBanner message={error ?? 'Issue not found'} onRetry={() => setTick((t) => t + 1)} />
135 </SafeAreaView>
136 );
137 }
138
139 const stateColor = issue.state === 'open' ? colors.accent : colors.accentRed;
140
141 return (
142 <SafeAreaView style={styles.safe} edges={['bottom']}>
143 <KeyboardAvoidingView
144 style={styles.flex}
145 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
146 keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
147 >
148 <FlatList
149 data={comments}
150 keyExtractor={keyExtractor}
151 renderItem={renderComment}
152 style={styles.list}
153 contentContainerStyle={styles.listContent}
154 ListHeaderComponent={
155 <View>
156 {error !== null && <ErrorBanner message={error} />}
157
158 {/* Issue header */}
159 <View style={styles.issueHeader}>
160 <View style={[styles.stateBadge, { backgroundColor: stateColor }]}>
161 <Text style={styles.stateText}>{issue.state}</Text>
162 </View>
163 <Text style={styles.issueTitle}>{issue.title}</Text>
164 <Text style={styles.issueMeta}>
165 #{issue.number} opened {formatRelative(issue.createdAt)} by {issue.authorUsername}
166 </Text>
167
168 {/* Labels */}
169 {issue.labels.length > 0 && (
170 <View style={styles.labelsRow}>
171 {issue.labels.map((label) => (
172 <View
173 key={label.id}
174 style={[styles.label, { borderColor: `#${label.color}` }]}
175 >
176 <Text style={[styles.labelText, { color: `#${label.color}` }]}>
177 {label.name}
178 </Text>
179 </View>
180 ))}
181 </View>
182 )}
183 </View>
184
185 {/* Issue body */}
186 {issue.body !== null && issue.body.length > 0 && (
187 <View style={styles.issueBody}>
188 <Text style={styles.bodyText}>{issue.body}</Text>
189 </View>
190 )}
191
192 {/* Comments section header */}
193 {comments.length > 0 && (
194 <View style={styles.commentsHeader}>
195 <Text style={styles.commentsHeaderText}>
196 {comments.length} {comments.length === 1 ? 'comment' : 'comments'}
197 </Text>
198 </View>
199 )}
200 </View>
201 }
202 ListEmptyComponent={
203 <View style={styles.noComments}>
204 <Text style={styles.noCommentsText}>No comments yet</Text>
205 </View>
206 }
207 />
208
209 {/* Comment input + actions */}
210 <View style={styles.inputArea}>
211 <View style={styles.inputRow}>
212 <TextInput
213 style={styles.commentInput}
214 value={commentText}
215 onChangeText={setCommentText}
216 placeholder="Leave a comment..."
217 placeholderTextColor={colors.textMuted}
218 multiline
219 maxLength={65536}
220 />
221 <TouchableOpacity
222 style={[styles.sendButton, (!commentText.trim() || isSubmitting) && styles.sendButtonDisabled]}
223 onPress={handleSubmitComment}
224 disabled={!commentText.trim() || isSubmitting}
225 activeOpacity={0.8}
226 >
227 <Text style={styles.sendIcon}></Text>
228 </TouchableOpacity>
229 </View>
230
231 {currentUser !== null && (
232 <TouchableOpacity
233 style={[styles.stateToggle, { borderColor: stateColor }, isSubmitting && styles.disabled]}
234 onPress={handleToggleState}
235 disabled={isSubmitting}
236 activeOpacity={0.8}
237 >
238 <Text style={[styles.stateToggleText, { color: stateColor }]}>
239 {issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
240 </Text>
241 </TouchableOpacity>
242 )}
243 </View>
244 </KeyboardAvoidingView>
245 </SafeAreaView>
246 );
247}
248
249const styles = StyleSheet.create({
250 safe: {
251 flex: 1,
252 backgroundColor: colors.bg,
253 },
254 flex: {
255 flex: 1,
256 },
257 list: {
258 flex: 1,
259 },
260 listContent: {
261 paddingBottom: 8,
262 },
263 issueHeader: {
264 padding: 16,
265 backgroundColor: colors.bgSecondary,
266 borderBottomWidth: 1,
267 borderBottomColor: colors.border,
268 gap: 8,
269 },
270 stateBadge: {
271 alignSelf: 'flex-start',
272 paddingHorizontal: 10,
273 paddingVertical: 4,
274 borderRadius: 20,
275 },
276 stateText: {
277 fontSize: 12,
278 fontWeight: '600',
279 color: colors.text,
280 textTransform: 'capitalize',
281 },
282 issueTitle: {
283 fontSize: 18,
284 fontWeight: '700',
285 color: colors.text,
286 lineHeight: 26,
287 },
288 issueMeta: {
289 fontSize: 13,
290 color: colors.textMuted,
291 },
292 labelsRow: {
293 flexDirection: 'row',
294 flexWrap: 'wrap',
295 gap: 6,
296 marginTop: 4,
297 },
298 label: {
299 paddingHorizontal: 8,
300 paddingVertical: 3,
301 borderRadius: 20,
302 borderWidth: 1,
303 },
304 labelText: {
305 fontSize: 11,
306 fontWeight: '500',
307 },
308 issueBody: {
309 padding: 16,
310 borderBottomWidth: 1,
311 borderBottomColor: colors.border,
312 },
313 bodyText: {
314 fontSize: 14,
315 color: colors.text,
316 lineHeight: 22,
317 },
318 commentsHeader: {
319 paddingHorizontal: 16,
320 paddingVertical: 10,
321 borderBottomWidth: 1,
322 borderBottomColor: colors.border,
323 backgroundColor: colors.bgTertiary,
324 },
325 commentsHeaderText: {
326 fontSize: 12,
327 fontWeight: '600',
328 color: colors.textMuted,
329 textTransform: 'uppercase',
330 letterSpacing: 0.5,
331 },
332 commentCard: {
333 padding: 16,
334 borderBottomWidth: 1,
335 borderBottomColor: colors.border,
336 backgroundColor: colors.bgSecondary,
337 gap: 8,
338 },
339 commentHeader: {
340 flexDirection: 'row',
341 alignItems: 'center',
342 justifyContent: 'space-between',
343 },
344 commentAuthor: {
345 fontSize: 13,
346 fontWeight: '600',
347 color: colors.text,
348 },
349 commentTime: {
350 fontSize: 12,
351 color: colors.textMuted,
352 },
353 commentBody: {
354 fontSize: 14,
355 color: colors.text,
356 lineHeight: 20,
357 },
358 noComments: {
359 padding: 32,
360 alignItems: 'center',
361 },
362 noCommentsText: {
363 fontSize: 14,
364 color: colors.textMuted,
365 },
366 inputArea: {
367 padding: 12,
368 backgroundColor: colors.bgSecondary,
369 borderTopWidth: 1,
370 borderTopColor: colors.border,
371 gap: 8,
372 },
373 inputRow: {
374 flexDirection: 'row',
375 alignItems: 'flex-end',
376 gap: 10,
377 },
378 commentInput: {
379 flex: 1,
380 backgroundColor: colors.bg,
381 borderWidth: 1,
382 borderColor: colors.border,
383 borderRadius: 8,
384 padding: 10,
385 fontSize: 14,
386 color: colors.text,
387 maxHeight: 100,
388 minHeight: 44,
389 },
390 sendButton: {
391 width: 40,
392 height: 40,
393 borderRadius: 20,
394 backgroundColor: colors.accentBlue,
395 alignItems: 'center',
396 justifyContent: 'center',
397 },
398 sendButtonDisabled: {
399 opacity: 0.4,
400 },
401 sendIcon: {
402 fontSize: 18,
403 color: colors.bg,
404 fontWeight: '700',
405 },
406 stateToggle: {
407 paddingVertical: 10,
408 borderRadius: 8,
409 borderWidth: 1,
410 alignItems: 'center',
411 },
412 stateToggleText: {
413 fontSize: 14,
414 fontWeight: '600',
415 },
416 disabled: {
417 opacity: 0.5,
418 },
419});
Addedmobile/src/screens/IssuesScreen.tsx+176−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 StyleSheet,
5 Text,
6 TouchableOpacity,
7 View,
8} from 'react-native';
9import { SafeAreaView } from 'react-native-safe-area-context';
10import type { NativeStackScreenProps } from '@react-navigation/native-stack';
11import { listIssues } from '../api/issues';
12import type { Issue } from '../api/issues';
13import { IssueRow } from '../components/IssueRow';
14import { LoadingSpinner } from '../components/LoadingSpinner';
15import { ErrorBanner } from '../components/ErrorBanner';
16import { colors } from '../theme/colors';
17import type { RepoStackParamList } from '../navigation/AppNavigator';
18
19type Props = NativeStackScreenProps<RepoStackParamList, 'Issues'>;
20type FilterState = 'open' | 'closed';
21
22export function IssuesScreen({ route, navigation }: Props): React.ReactElement {
23 const { owner, repo } = route.params;
24 const [filter, setFilter] = useState<FilterState>('open');
25 const [issues, setIssues] = useState<Issue[]>([]);
26 const [isLoading, setIsLoading] = useState(true);
27 const [error, setError] = useState<string | null>(null);
28 const [page, setPage] = useState(1);
29 const [hasMore, setHasMore] = useState(true);
30
31 useEffect(() => {
32 let cancelled = false;
33 setIsLoading(true);
34 setError(null);
35
36 listIssues(owner, repo, filter, 1)
37 .then((data) => {
38 if (!cancelled) {
39 setIssues(data);
40 setPage(1);
41 setHasMore(data.length === 30);
42 }
43 })
44 .catch((err) => {
45 if (!cancelled) {
46 setError(err instanceof Error ? err.message : 'Failed to load issues');
47 }
48 })
49 .finally(() => {
50 if (!cancelled) setIsLoading(false);
51 });
52
53 return () => {
54 cancelled = true;
55 };
56 }, [owner, repo, filter]);
57
58 const loadMore = useCallback(() => {
59 if (!hasMore || isLoading) return;
60 const nextPage = page + 1;
61
62 listIssues(owner, repo, filter, nextPage)
63 .then((data) => {
64 setIssues((prev) => [...prev, ...data]);
65 setPage(nextPage);
66 setHasMore(data.length === 30);
67 })
68 .catch(() => {/* ignore pagination errors */});
69 }, [owner, repo, filter, page, hasMore, isLoading]);
70
71 const handleIssuePress = useCallback(
72 (issue: Issue) => {
73 navigation.navigate('IssueDetail', { owner, repo, number: issue.number });
74 },
75 [navigation, owner, repo],
76 );
77
78 const renderIssue = useCallback(
79 ({ item }: { item: Issue }) => <IssueRow issue={item} onPress={handleIssuePress} />,
80 [handleIssuePress],
81 );
82
83 const keyExtractor = useCallback((item: Issue) => String(item.id), []);
84
85 const renderFooter = useCallback(
86 () => (hasMore ? <LoadingSpinner size="small" /> : null),
87 [hasMore],
88 );
89
90 if (isLoading && issues.length === 0) return <LoadingSpinner fullScreen />;
91
92 return (
93 <SafeAreaView style={styles.safe} edges={['bottom']}>
94 {/* Filter tabs */}
95 <View style={styles.filterTabs}>
96 <TouchableOpacity
97 style={[styles.filterTab, filter === 'open' && styles.filterTabActive]}
98 onPress={() => setFilter('open')}
99 >
100 <Text style={[styles.filterTabText, filter === 'open' && styles.filterTabTextActive]}>
101 Open
102 </Text>
103 </TouchableOpacity>
104 <TouchableOpacity
105 style={[styles.filterTab, filter === 'closed' && styles.filterTabActive]}
106 onPress={() => setFilter('closed')}
107 >
108 <Text style={[styles.filterTabText, filter === 'closed' && styles.filterTabTextActive]}>
109 Closed
110 </Text>
111 </TouchableOpacity>
112 </View>
113
114 {error !== null && <ErrorBanner message={error} />}
115
116 <FlatList
117 data={issues}
118 keyExtractor={keyExtractor}
119 renderItem={renderIssue}
120 onEndReached={loadMore}
121 onEndReachedThreshold={0.3}
122 ListFooterComponent={renderFooter}
123 ListEmptyComponent={
124 <View style={styles.emptyState}>
125 <Text style={styles.emptyText}>
126 No {filter} issues
127 </Text>
128 </View>
129 }
130 style={styles.list}
131 />
132 </SafeAreaView>
133 );
134}
135
136const styles = StyleSheet.create({
137 safe: {
138 flex: 1,
139 backgroundColor: colors.bg,
140 },
141 filterTabs: {
142 flexDirection: 'row',
143 backgroundColor: colors.bgSecondary,
144 borderBottomWidth: 1,
145 borderBottomColor: colors.border,
146 },
147 filterTab: {
148 flex: 1,
149 paddingVertical: 12,
150 alignItems: 'center',
151 borderBottomWidth: 2,
152 borderBottomColor: 'transparent',
153 },
154 filterTabActive: {
155 borderBottomColor: colors.accentBlue,
156 },
157 filterTabText: {
158 fontSize: 14,
159 fontWeight: '500',
160 color: colors.textMuted,
161 },
162 filterTabTextActive: {
163 color: colors.text,
164 },
165 list: {
166 flex: 1,
167 },
168 emptyState: {
169 padding: 40,
170 alignItems: 'center',
171 },
172 emptyText: {
173 fontSize: 14,
174 color: colors.textMuted,
175 },
176});
Addedmobile/src/screens/LoginScreen.tsx+249−0View fileUnifiedSplit
1import React, { useState, useCallback } from 'react';
2import {
3 KeyboardAvoidingView,
4 Platform,
5 ScrollView,
6 StyleSheet,
7 Text,
8 TextInput,
9 TouchableOpacity,
10 View,
11 ActivityIndicator,
12} from 'react-native';
13import { StatusBar } from 'expo-status-bar';
14import { useAuth } from '../hooks/useAuth';
15import { colors } from '../theme/colors';
16
17export function LoginScreen(): React.ReactElement {
18 const { login, isLoading, error } = useAuth();
19
20 const [host, setHost] = useState('https://gluecron.com');
21 const [token, setToken] = useState('');
22 const [localError, setLocalError] = useState<string | null>(null);
23
24 const handleConnect = useCallback(async () => {
25 setLocalError(null);
26 const trimmedHost = host.trim();
27 const trimmedToken = token.trim();
28
29 if (!trimmedHost) {
30 setLocalError('Host URL is required');
31 return;
32 }
33 if (!trimmedToken) {
34 setLocalError('Personal Access Token is required');
35 return;
36 }
37 if (!trimmedToken.startsWith('glc_')) {
38 setLocalError('Token must start with glc_');
39 return;
40 }
41
42 try {
43 await login(trimmedHost, trimmedToken);
44 } catch (err) {
45 setLocalError(err instanceof Error ? err.message : 'Login failed');
46 }
47 }, [host, token, login]);
48
49 const displayError = localError ?? error;
50
51 return (
52 <KeyboardAvoidingView
53 style={styles.flex}
54 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
55 >
56 <StatusBar style="light" />
57 <ScrollView
58 contentContainerStyle={styles.container}
59 keyboardShouldPersistTaps="handled"
60 >
61 {/* Branding */}
62 <View style={styles.brandSection}>
63 <View style={styles.logoContainer}>
64 <Text style={styles.logoText}></Text>
65 </View>
66 <Text style={styles.appName}>Gluecron</Text>
67 <Text style={styles.tagline}>AI-native code intelligence platform</Text>
68 </View>
69
70 {/* Form */}
71 <View style={styles.card}>
72 <Text style={styles.cardTitle}>Connect to your instance</Text>
73
74 <View style={styles.formGroup}>
75 <Text style={styles.label}>Host URL</Text>
76 <TextInput
77 style={styles.input}
78 value={host}
79 onChangeText={setHost}
80 placeholder="https://gluecron.com"
81 placeholderTextColor={colors.textMuted}
82 autoCapitalize="none"
83 autoCorrect={false}
84 keyboardType="url"
85 textContentType="URL"
86 />
87 </View>
88
89 <View style={styles.formGroup}>
90 <Text style={styles.label}>Personal Access Token</Text>
91 <TextInput
92 style={styles.input}
93 value={token}
94 onChangeText={setToken}
95 placeholder="glc_..."
96 placeholderTextColor={colors.textMuted}
97 autoCapitalize="none"
98 autoCorrect={false}
99 secureTextEntry
100 textContentType="password"
101 />
102 <Text style={styles.hint}>
103 Generate a token in Settings → Personal Access Tokens on your Gluecron instance.
104 </Text>
105 </View>
106
107 {displayError !== null && (
108 <View style={styles.errorBox}>
109 <Text style={styles.errorText}>{displayError}</Text>
110 </View>
111 )}
112
113 <TouchableOpacity
114 style={[styles.button, isLoading && styles.buttonDisabled]}
115 onPress={handleConnect}
116 disabled={isLoading}
117 activeOpacity={0.8}
118 >
119 {isLoading ? (
120 <ActivityIndicator size="small" color={colors.text} />
121 ) : (
122 <Text style={styles.buttonText}>Connect</Text>
123 )}
124 </TouchableOpacity>
125 </View>
126
127 {/* Footer */}
128 <Text style={styles.footer}>
129 gluecron.com — git hosting, AI code review, gate enforcement
130 </Text>
131 </ScrollView>
132 </KeyboardAvoidingView>
133 );
134}
135
136const styles = StyleSheet.create({
137 flex: {
138 flex: 1,
139 backgroundColor: colors.bg,
140 },
141 container: {
142 flexGrow: 1,
143 justifyContent: 'center',
144 paddingHorizontal: 24,
145 paddingVertical: 48,
146 gap: 32,
147 },
148 brandSection: {
149 alignItems: 'center',
150 gap: 10,
151 },
152 logoContainer: {
153 width: 72,
154 height: 72,
155 borderRadius: 18,
156 backgroundColor: colors.bgSecondary,
157 borderWidth: 1,
158 borderColor: colors.border,
159 alignItems: 'center',
160 justifyContent: 'center',
161 },
162 logoText: {
163 fontSize: 36,
164 color: colors.accentBlue,
165 },
166 appName: {
167 fontSize: 28,
168 fontWeight: '700',
169 color: colors.text,
170 letterSpacing: -0.5,
171 },
172 tagline: {
173 fontSize: 14,
174 color: colors.textMuted,
175 textAlign: 'center',
176 },
177 card: {
178 backgroundColor: colors.bgSecondary,
179 borderRadius: 12,
180 borderWidth: 1,
181 borderColor: colors.border,
182 padding: 20,
183 gap: 16,
184 },
185 cardTitle: {
186 fontSize: 16,
187 fontWeight: '600',
188 color: colors.text,
189 marginBottom: 4,
190 },
191 formGroup: {
192 gap: 6,
193 },
194 label: {
195 fontSize: 13,
196 fontWeight: '500',
197 color: colors.textMuted,
198 textTransform: 'uppercase',
199 letterSpacing: 0.5,
200 },
201 input: {
202 backgroundColor: colors.bg,
203 borderWidth: 1,
204 borderColor: colors.border,
205 borderRadius: 8,
206 paddingHorizontal: 14,
207 paddingVertical: 12,
208 fontSize: 15,
209 color: colors.text,
210 },
211 hint: {
212 fontSize: 12,
213 color: colors.textMuted,
214 lineHeight: 16,
215 },
216 errorBox: {
217 backgroundColor: colors.bg,
218 borderWidth: 1,
219 borderColor: colors.accentRed,
220 borderRadius: 8,
221 padding: 12,
222 },
223 errorText: {
224 fontSize: 13,
225 color: colors.accentRed,
226 lineHeight: 18,
227 },
228 button: {
229 backgroundColor: colors.accent,
230 borderRadius: 8,
231 paddingVertical: 14,
232 alignItems: 'center',
233 justifyContent: 'center',
234 minHeight: 48,
235 },
236 buttonDisabled: {
237 opacity: 0.6,
238 },
239 buttonText: {
240 fontSize: 15,
241 fontWeight: '600',
242 color: colors.text,
243 },
244 footer: {
245 fontSize: 12,
246 color: colors.textMuted,
247 textAlign: 'center',
248 },
249});
Addedmobile/src/screens/NotificationsScreen.tsx+342−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 StyleSheet,
5 Text,
6 TouchableOpacity,
7 View,
8} from 'react-native';
9import { SafeAreaView } from 'react-native-safe-area-context';
10import {
11 listNotifications,
12 markNotificationRead,
13 markAllRead,
14} from '../api/notifications';
15import type { Notification } from '../api/notifications';
16import { LoadingSpinner } from '../components/LoadingSpinner';
17import { ErrorBanner } from '../components/ErrorBanner';
18import { colors } from '../theme/colors';
19
20function typeIcon(type: Notification['type']): string {
21 switch (type) {
22 case 'issue_opened': return '○';
23 case 'issue_closed': return '●';
24 case 'issue_comment': return '💬';
25 case 'pr_opened': return '⑂';
26 case 'pr_merged': return '✓';
27 case 'pr_closed': return '✗';
28 case 'pr_comment': return '💬';
29 case 'pr_review': return '✦';
30 case 'push': return '↑';
31 case 'star': return '★';
32 case 'fork': return '⑂';
33 case 'gate_failed': return '✗';
34 case 'gate_passed': return '✓';
35 case 'mention': return '@';
36 default: return '•';
37 }
38}
39
40function typeColor(type: Notification['type']): string {
41 switch (type) {
42 case 'gate_failed':
43 case 'issue_closed':
44 case 'pr_closed': return colors.accentRed;
45 case 'gate_passed':
46 case 'pr_merged': return colors.accentPurple;
47 case 'pr_review': return colors.accentPurple;
48 case 'star': return colors.accentYellow;
49 default: return colors.accentBlue;
50 }
51}
52
53function formatRelative(dateStr: string): string {
54 const diff = Date.now() - new Date(dateStr).getTime();
55 const mins = Math.floor(diff / 60000);
56 if (mins < 1) return 'just now';
57 if (mins < 60) return `${mins}m ago`;
58 const hrs = Math.floor(mins / 60);
59 if (hrs < 24) return `${hrs}h ago`;
60 const days = Math.floor(hrs / 24);
61 if (days < 30) return `${days}d ago`;
62 return `${Math.floor(days / 30)}mo ago`;
63}
64
65interface NotifRowProps {
66 notif: Notification;
67 onRead: (id: string) => void;
68}
69
70function NotifRow({ notif, onRead }: NotifRowProps): React.ReactElement {
71 const handlePress = useCallback(() => {
72 if (!notif.isRead) onRead(notif.id);
73 }, [notif.id, notif.isRead, onRead]);
74
75 const iconColor = typeColor(notif.type);
76
77 return (
78 <TouchableOpacity
79 style={[styles.row, notif.isRead && styles.rowRead]}
80 onPress={handlePress}
81 activeOpacity={0.75}
82 >
83 <View style={[styles.iconContainer, { backgroundColor: iconColor + '22' }]}>
84 <Text style={[styles.icon, { color: iconColor }]}>{typeIcon(notif.type)}</Text>
85 </View>
86 <View style={styles.content}>
87 <Text style={styles.title} numberOfLines={2}>{notif.title}</Text>
88 {notif.body !== null && (
89 <Text style={styles.body} numberOfLines={1}>{notif.body}</Text>
90 )}
91 <View style={styles.meta}>
92 {notif.repoOwner !== null && notif.repoName !== null && (
93 <Text style={styles.repoText}>{notif.repoOwner}/{notif.repoName}</Text>
94 )}
95 <Text style={styles.time}>{formatRelative(notif.createdAt)}</Text>
96 </View>
97 </View>
98 {!notif.isRead && <View style={styles.unreadDot} />}
99 </TouchableOpacity>
100 );
101}
102
103type FilterState = 'unread' | 'all';
104
105export function NotificationsScreen(): React.ReactElement {
106 const [filter, setFilter] = useState<FilterState>('unread');
107 const [notifications, setNotifications] = useState<Notification[]>([]);
108 const [isLoading, setIsLoading] = useState(true);
109 const [error, setError] = useState<string | null>(null);
110 const [tick, setTick] = useState(0);
111
112 useEffect(() => {
113 let cancelled = false;
114 setIsLoading(true);
115 setError(null);
116
117 listNotifications(filter)
118 .then((data) => {
119 if (!cancelled) setNotifications(data);
120 })
121 .catch((err) => {
122 if (!cancelled) {
123 setError(err instanceof Error ? err.message : 'Failed to load notifications');
124 }
125 })
126 .finally(() => {
127 if (!cancelled) setIsLoading(false);
128 });
129
130 return () => {
131 cancelled = true;
132 };
133 }, [filter, tick]);
134
135 const handleRead = useCallback(async (id: string) => {
136 setNotifications((prev) =>
137 prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)),
138 );
139 try {
140 await markNotificationRead(id);
141 } catch {/* ignore */}
142 }, []);
143
144 const handleMarkAllRead = useCallback(async () => {
145 setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
146 try {
147 await markAllRead();
148 setTick((t) => t + 1);
149 } catch {/* ignore */}
150 }, []);
151
152 const renderNotif = useCallback(
153 ({ item }: { item: Notification }) => (
154 <NotifRow notif={item} onRead={handleRead} />
155 ),
156 [handleRead],
157 );
158
159 const keyExtractor = useCallback((item: Notification) => item.id, []);
160 const unreadCount = notifications.filter((n) => !n.isRead).length;
161
162 if (isLoading && notifications.length === 0) return <LoadingSpinner fullScreen />;
163
164 return (
165 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
166 {/* Header */}
167 <View style={styles.header}>
168 <Text style={styles.headerTitle}>Notifications</Text>
169 {unreadCount > 0 && (
170 <TouchableOpacity onPress={handleMarkAllRead}>
171 <Text style={styles.markAllText}>Mark all read</Text>
172 </TouchableOpacity>
173 )}
174 </View>
175
176 {/* Filter tabs */}
177 <View style={styles.filterTabs}>
178 <TouchableOpacity
179 style={[styles.filterTab, filter === 'unread' && styles.filterTabActive]}
180 onPress={() => setFilter('unread')}
181 >
182 <Text style={[styles.filterTabText, filter === 'unread' && styles.filterTabTextActive]}>
183 Unread
184 {unreadCount > 0 && ` (${unreadCount})`}
185 </Text>
186 </TouchableOpacity>
187 <TouchableOpacity
188 style={[styles.filterTab, filter === 'all' && styles.filterTabActive]}
189 onPress={() => setFilter('all')}
190 >
191 <Text style={[styles.filterTabText, filter === 'all' && styles.filterTabTextActive]}>
192 All
193 </Text>
194 </TouchableOpacity>
195 </View>
196
197 {error !== null && <ErrorBanner message={error} onRetry={() => setTick((t) => t + 1)} />}
198
199 <FlatList
200 data={notifications}
201 keyExtractor={keyExtractor}
202 renderItem={renderNotif}
203 style={styles.list}
204 ListEmptyComponent={
205 <View style={styles.emptyState}>
206 <Text style={styles.emptyIcon}>🔔</Text>
207 <Text style={styles.emptyText}>
208 {filter === 'unread' ? "You're all caught up!" : 'No notifications'}
209 </Text>
210 </View>
211 }
212 />
213 </SafeAreaView>
214 );
215}
216
217const styles = StyleSheet.create({
218 safe: {
219 flex: 1,
220 backgroundColor: colors.bg,
221 },
222 header: {
223 flexDirection: 'row',
224 alignItems: 'center',
225 justifyContent: 'space-between',
226 paddingHorizontal: 16,
227 paddingVertical: 14,
228 backgroundColor: colors.bgSecondary,
229 borderBottomWidth: 1,
230 borderBottomColor: colors.border,
231 },
232 headerTitle: {
233 fontSize: 18,
234 fontWeight: '700',
235 color: colors.text,
236 },
237 markAllText: {
238 fontSize: 13,
239 color: colors.textLink,
240 },
241 filterTabs: {
242 flexDirection: 'row',
243 backgroundColor: colors.bgSecondary,
244 borderBottomWidth: 1,
245 borderBottomColor: colors.border,
246 },
247 filterTab: {
248 flex: 1,
249 paddingVertical: 12,
250 alignItems: 'center',
251 borderBottomWidth: 2,
252 borderBottomColor: 'transparent',
253 },
254 filterTabActive: {
255 borderBottomColor: colors.accentBlue,
256 },
257 filterTabText: {
258 fontSize: 14,
259 fontWeight: '500',
260 color: colors.textMuted,
261 },
262 filterTabTextActive: {
263 color: colors.text,
264 },
265 list: {
266 flex: 1,
267 },
268 row: {
269 flexDirection: 'row',
270 alignItems: 'flex-start',
271 padding: 14,
272 borderBottomWidth: 1,
273 borderBottomColor: colors.border,
274 backgroundColor: colors.bgSecondary,
275 gap: 12,
276 },
277 rowRead: {
278 opacity: 0.6,
279 backgroundColor: colors.bg,
280 },
281 iconContainer: {
282 width: 36,
283 height: 36,
284 borderRadius: 8,
285 alignItems: 'center',
286 justifyContent: 'center',
287 flexShrink: 0,
288 },
289 icon: {
290 fontSize: 16,
291 fontWeight: '700',
292 },
293 content: {
294 flex: 1,
295 gap: 4,
296 },
297 title: {
298 fontSize: 14,
299 fontWeight: '500',
300 color: colors.text,
301 lineHeight: 20,
302 },
303 body: {
304 fontSize: 13,
305 color: colors.textMuted,
306 },
307 meta: {
308 flexDirection: 'row',
309 alignItems: 'center',
310 justifyContent: 'space-between',
311 },
312 repoText: {
313 fontSize: 12,
314 color: colors.textMuted,
315 fontFamily: 'monospace',
316 },
317 time: {
318 fontSize: 12,
319 color: colors.textMuted,
320 },
321 unreadDot: {
322 width: 8,
323 height: 8,
324 borderRadius: 4,
325 backgroundColor: colors.accentBlue,
326 marginTop: 6,
327 flexShrink: 0,
328 },
329 emptyState: {
330 padding: 48,
331 alignItems: 'center',
332 gap: 10,
333 },
334 emptyIcon: {
335 fontSize: 36,
336 },
337 emptyText: {
338 fontSize: 15,
339 color: colors.textMuted,
340 textAlign: 'center',
341 },
342});
Addedmobile/src/screens/PullDetailScreen.tsx+639−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 KeyboardAvoidingView,
5 Platform,
6 ScrollView,
7 StyleSheet,
8 Text,
9 TextInput,
10 TouchableOpacity,
11 View,
12 ActivityIndicator,
13} from 'react-native';
14import { SafeAreaView } from 'react-native-safe-area-context';
15import type { NativeStackScreenProps } from '@react-navigation/native-stack';
16import {
17 getPullRequest,
18 getPrComments,
19 getAiReview,
20 createPrComment,
21 mergePullRequest,
22 closePullRequest,
23} from '../api/pulls';
24import type { PullRequest, PrComment, AiReviewSummary } from '../api/pulls';
25import { AiReviewCard } from '../components/AiReviewCard';
26import { GateStatusBadge } from '../components/GateStatusBadge';
27import { LoadingSpinner } from '../components/LoadingSpinner';
28import { ErrorBanner } from '../components/ErrorBanner';
29import { colors } from '../theme/colors';
30import type { RepoStackParamList } from '../navigation/AppNavigator';
31import { useAuthStore } from '../store/authStore';
32
33type Props = NativeStackScreenProps<RepoStackParamList, 'PullDetail'>;
34
35function formatRelative(dateStr: string): string {
36 const diff = Date.now() - new Date(dateStr).getTime();
37 const mins = Math.floor(diff / 60000);
38 if (mins < 1) return 'just now';
39 if (mins < 60) return `${mins}m ago`;
40 const hrs = Math.floor(mins / 60);
41 if (hrs < 24) return `${hrs}h ago`;
42 const days = Math.floor(hrs / 24);
43 if (days < 30) return `${days}d ago`;
44 const months = Math.floor(days / 30);
45 if (months < 12) return `${months}mo ago`;
46 return `${Math.floor(months / 12)}y ago`;
47}
48
49function prStateColor(state: PullRequest['state']): string {
50 switch (state) {
51 case 'open': return colors.accent;
52 case 'merged': return colors.accentPurple;
53 case 'closed': return colors.accentRed;
54 }
55}
56
57function HumanComment({ comment }: { comment: PrComment }): React.ReactElement {
58 return (
59 <View style={styles.commentCard}>
60 <View style={styles.commentHeader}>
61 <Text style={styles.commentAuthor}>{comment.authorUsername}</Text>
62 {comment.isAiReview && (
63 <View style={styles.aiTag}>
64 <Text style={styles.aiTagText}>✦ AI</Text>
65 </View>
66 )}
67 <Text style={styles.commentTime}>{formatRelative(comment.createdAt)}</Text>
68 </View>
69 {comment.filePath !== null && (
70 <View style={styles.fileRef}>
71 <Text style={styles.fileRefText} numberOfLines={1}>
72 {comment.filePath}{comment.lineNumber !== null ? `:${comment.lineNumber}` : ''}
73 </Text>
74 </View>
75 )}
76 <Text style={styles.commentBody}>{comment.body}</Text>
77 </View>
78 );
79}
80
81export function PullDetailScreen({ route }: Props): React.ReactElement {
82 const { owner, repo, number } = route.params;
83 const currentUser = useAuthStore((s) => s.user);
84
85 const [pr, setPr] = useState<PullRequest | null>(null);
86 const [comments, setComments] = useState<PrComment[]>([]);
87 const [aiReview, setAiReview] = useState<AiReviewSummary | null>(null);
88 const [isLoading, setIsLoading] = useState(true);
89 const [error, setError] = useState<string | null>(null);
90 const [commentText, setCommentText] = useState('');
91 const [isSubmitting, setIsSubmitting] = useState(false);
92 const [mergeSuccess, setMergeSuccess] = useState(false);
93 const [tick, setTick] = useState(0);
94
95 useEffect(() => {
96 let cancelled = false;
97 setIsLoading(true);
98 setError(null);
99
100 Promise.all([
101 getPullRequest(owner, repo, number),
102 getPrComments(owner, repo, number),
103 getAiReview(owner, repo, number),
104 ])
105 .then(([prData, commentsData, reviewData]) => {
106 if (!cancelled) {
107 setPr(prData);
108 // Human comments: exclude inline AI comments (those are shown in AiReviewCard)
109 setComments(commentsData.filter((c) => !c.isAiReview));
110 setAiReview(reviewData);
111 }
112 })
113 .catch((err) => {
114 if (!cancelled) {
115 setError(err instanceof Error ? err.message : 'Failed to load pull request');
116 }
117 })
118 .finally(() => {
119 if (!cancelled) setIsLoading(false);
120 });
121
122 return () => {
123 cancelled = true;
124 };
125 }, [owner, repo, number, tick]);
126
127 const handleMerge = useCallback(async () => {
128 if (!pr || isSubmitting) return;
129 setIsSubmitting(true);
130 setError(null);
131 try {
132 await mergePullRequest(owner, repo, number);
133 setMergeSuccess(true);
134 setTick((t) => t + 1);
135 } catch (err) {
136 setError(err instanceof Error ? err.message : 'Merge failed');
137 } finally {
138 setIsSubmitting(false);
139 }
140 }, [pr, owner, repo, number, isSubmitting]);
141
142 const handleClose = useCallback(async () => {
143 if (!pr || isSubmitting) return;
144 setIsSubmitting(true);
145 setError(null);
146 try {
147 const updated = await closePullRequest(owner, repo, number);
148 setPr(updated);
149 } catch (err) {
150 setError(err instanceof Error ? err.message : 'Failed to close PR');
151 } finally {
152 setIsSubmitting(false);
153 }
154 }, [pr, owner, repo, number, isSubmitting]);
155
156 const handleSubmitComment = useCallback(async () => {
157 const body = commentText.trim();
158 if (!body || isSubmitting) return;
159 setIsSubmitting(true);
160 try {
161 const newComment = await createPrComment(owner, repo, number, { body });
162 setComments((prev) => [...prev, newComment]);
163 setCommentText('');
164 } catch (err) {
165 setError(err instanceof Error ? err.message : 'Failed to post comment');
166 } finally {
167 setIsSubmitting(false);
168 }
169 }, [owner, repo, number, commentText, isSubmitting]);
170
171 const renderComment = useCallback(
172 ({ item }: { item: PrComment }) => <HumanComment comment={item} />,
173 [],
174 );
175
176 const keyExtractor = useCallback((item: PrComment) => String(item.id), []);
177
178 const canMerge =
179 pr?.state === 'open' &&
180 currentUser !== null &&
181 pr.gateStatus !== 'failed' &&
182 !mergeSuccess;
183
184 if (isLoading) return <LoadingSpinner fullScreen />;
185
186 if (pr === null) {
187 return (
188 <SafeAreaView style={styles.safe} edges={['bottom']}>
189 <ErrorBanner
190 message={error ?? 'Pull request not found'}
191 onRetry={() => setTick((t) => t + 1)}
192 />
193 </SafeAreaView>
194 );
195 }
196
197 const stateColor = prStateColor(pr.state);
198
199 return (
200 <SafeAreaView style={styles.safe} edges={['bottom']}>
201 <KeyboardAvoidingView
202 style={styles.flex}
203 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
204 keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
205 >
206 <FlatList
207 data={comments}
208 keyExtractor={keyExtractor}
209 renderItem={renderComment}
210 style={styles.list}
211 contentContainerStyle={styles.listContent}
212 ListHeaderComponent={
213 <View>
214 {error !== null && <ErrorBanner message={error} />}
215
216 {/* PR header */}
217 <View style={styles.prHeader}>
218 <View style={styles.titleRow}>
219 <View style={[styles.stateBadge, { backgroundColor: stateColor }]}>
220 <Text style={styles.stateText}>{pr.state}</Text>
221 </View>
222 {pr.isDraft && (
223 <View style={styles.draftBadge}>
224 <Text style={styles.draftText}>Draft</Text>
225 </View>
226 )}
227 </View>
228
229 <Text style={styles.prTitle}>{pr.title}</Text>
230
231 <Text style={styles.prMeta}>
232 #{pr.number} by {pr.authorUsername} · {formatRelative(pr.createdAt)}
233 </Text>
234
235 {/* Branch info */}
236 <View style={styles.branchInfo}>
237 <Text style={styles.branchText}>{pr.headBranch}</Text>
238 <Text style={styles.branchArrow}></Text>
239 <Text style={styles.branchText}>{pr.baseBranch}</Text>
240 </View>
241
242 {/* Gate status */}
243 <View style={styles.gateRow}>
244 <Text style={styles.gateLabel}>Gate:</Text>
245 <GateStatusBadge
246 status={pr.gateStatus === 'none' ? 'pending' : pr.gateStatus}
247 size="small"
248 />
249 </View>
250
251 {/* Stats */}
252 <View style={styles.statsRow}>
253 <Text style={styles.statItem}>
254 <Text style={{ color: colors.accent }}>+{pr.additions}</Text>
255 {' '}
256 <Text style={{ color: colors.accentRed }}>-{pr.deletions}</Text>
257 </Text>
258 <Text style={styles.statDot}>·</Text>
259 <Text style={styles.statItem}>{pr.changedFiles} files</Text>
260 <Text style={styles.statDot}>·</Text>
261 <Text style={styles.statItem}>{pr.commitCount} commits</Text>
262 </View>
263 </View>
264
265 {/* PR body */}
266 {pr.body !== null && pr.body.length > 0 && (
267 <View style={styles.prBody}>
268 <Text style={styles.bodyText}>{pr.body}</Text>
269 </View>
270 )}
271
272 {/* AI Review */}
273 {aiReview !== null && (
274 <View style={styles.aiSection}>
275 <AiReviewCard review={aiReview} />
276 </View>
277 )}
278
279 {/* Merge / Close buttons */}
280 {pr.state === 'open' && currentUser !== null && (
281 <View style={styles.actionsRow}>
282 <TouchableOpacity
283 style={[styles.mergeButton, !canMerge && styles.buttonDisabled]}
284 onPress={handleMerge}
285 disabled={!canMerge || isSubmitting}
286 activeOpacity={0.8}
287 >
288 {isSubmitting ? (
289 <ActivityIndicator size="small" color={colors.text} />
290 ) : (
291 <Text style={styles.mergeText}>
292 {mergeSuccess ? 'Merged!' : 'Merge Pull Request'}
293 </Text>
294 )}
295 </TouchableOpacity>
296 <TouchableOpacity
297 style={[styles.closeButton, isSubmitting && styles.buttonDisabled]}
298 onPress={handleClose}
299 disabled={isSubmitting}
300 activeOpacity={0.8}
301 >
302 <Text style={styles.closeText}>Close PR</Text>
303 </TouchableOpacity>
304 </View>
305 )}
306
307 {pr.state === 'merged' && (
308 <View style={styles.mergedBanner}>
309 <Text style={styles.mergedBannerText}>
310 ✓ Merged by {pr.mergedByUsername ?? 'unknown'} · {pr.mergedAt !== null ? formatRelative(pr.mergedAt) : ''}
311 </Text>
312 </View>
313 )}
314
315 {/* Comments header */}
316 {comments.length > 0 && (
317 <View style={styles.commentsHeader}>
318 <Text style={styles.commentsHeaderText}>
319 {comments.length} {comments.length === 1 ? 'review comment' : 'review comments'}
320 </Text>
321 </View>
322 )}
323 </View>
324 }
325 ListEmptyComponent={
326 <View style={styles.noComments}>
327 <Text style={styles.noCommentsText}>No review comments yet</Text>
328 </View>
329 }
330 />
331
332 {/* Comment input */}
333 <View style={styles.inputArea}>
334 <View style={styles.inputRow}>
335 <TextInput
336 style={styles.commentInput}
337 value={commentText}
338 onChangeText={setCommentText}
339 placeholder="Leave a review comment..."
340 placeholderTextColor={colors.textMuted}
341 multiline
342 maxLength={65536}
343 />
344 <TouchableOpacity
345 style={[styles.sendButton, (!commentText.trim() || isSubmitting) && styles.sendButtonDisabled]}
346 onPress={handleSubmitComment}
347 disabled={!commentText.trim() || isSubmitting}
348 activeOpacity={0.8}
349 >
350 <Text style={styles.sendIcon}></Text>
351 </TouchableOpacity>
352 </View>
353 </View>
354 </KeyboardAvoidingView>
355 </SafeAreaView>
356 );
357}
358
359const styles = StyleSheet.create({
360 safe: {
361 flex: 1,
362 backgroundColor: colors.bg,
363 },
364 flex: {
365 flex: 1,
366 },
367 list: {
368 flex: 1,
369 },
370 listContent: {
371 paddingBottom: 8,
372 },
373 prHeader: {
374 padding: 16,
375 backgroundColor: colors.bgSecondary,
376 borderBottomWidth: 1,
377 borderBottomColor: colors.border,
378 gap: 10,
379 },
380 titleRow: {
381 flexDirection: 'row',
382 gap: 8,
383 flexWrap: 'wrap',
384 },
385 stateBadge: {
386 paddingHorizontal: 10,
387 paddingVertical: 4,
388 borderRadius: 20,
389 },
390 stateText: {
391 fontSize: 12,
392 fontWeight: '600',
393 color: colors.text,
394 textTransform: 'capitalize',
395 },
396 draftBadge: {
397 paddingHorizontal: 8,
398 paddingVertical: 4,
399 borderRadius: 20,
400 backgroundColor: colors.bgTertiary,
401 borderWidth: 1,
402 borderColor: colors.border,
403 },
404 draftText: {
405 fontSize: 12,
406 color: colors.textMuted,
407 },
408 prTitle: {
409 fontSize: 18,
410 fontWeight: '700',
411 color: colors.text,
412 lineHeight: 26,
413 },
414 prMeta: {
415 fontSize: 13,
416 color: colors.textMuted,
417 },
418 branchInfo: {
419 flexDirection: 'row',
420 alignItems: 'center',
421 backgroundColor: colors.bg,
422 borderRadius: 6,
423 paddingHorizontal: 10,
424 paddingVertical: 6,
425 borderWidth: 1,
426 borderColor: colors.border,
427 alignSelf: 'flex-start',
428 },
429 branchText: {
430 fontSize: 12,
431 fontFamily: 'monospace',
432 color: colors.textLink,
433 },
434 branchArrow: {
435 fontSize: 12,
436 color: colors.textMuted,
437 },
438 gateRow: {
439 flexDirection: 'row',
440 alignItems: 'center',
441 gap: 8,
442 },
443 gateLabel: {
444 fontSize: 13,
445 color: colors.textMuted,
446 },
447 statsRow: {
448 flexDirection: 'row',
449 alignItems: 'center',
450 gap: 6,
451 },
452 statItem: {
453 fontSize: 13,
454 color: colors.textMuted,
455 },
456 statDot: {
457 fontSize: 13,
458 color: colors.textMuted,
459 },
460 prBody: {
461 padding: 16,
462 borderBottomWidth: 1,
463 borderBottomColor: colors.border,
464 },
465 bodyText: {
466 fontSize: 14,
467 color: colors.text,
468 lineHeight: 22,
469 },
470 aiSection: {
471 paddingTop: 16,
472 },
473 actionsRow: {
474 flexDirection: 'row',
475 padding: 16,
476 gap: 12,
477 borderBottomWidth: 1,
478 borderBottomColor: colors.border,
479 },
480 mergeButton: {
481 flex: 2,
482 backgroundColor: colors.accent,
483 borderRadius: 8,
484 paddingVertical: 12,
485 alignItems: 'center',
486 justifyContent: 'center',
487 minHeight: 44,
488 },
489 mergeText: {
490 fontSize: 14,
491 fontWeight: '600',
492 color: colors.text,
493 },
494 closeButton: {
495 flex: 1,
496 borderRadius: 8,
497 paddingVertical: 12,
498 alignItems: 'center',
499 borderWidth: 1,
500 borderColor: colors.accentRed,
501 minHeight: 44,
502 },
503 closeText: {
504 fontSize: 14,
505 fontWeight: '600',
506 color: colors.accentRed,
507 },
508 buttonDisabled: {
509 opacity: 0.5,
510 },
511 mergedBanner: {
512 margin: 16,
513 padding: 12,
514 borderRadius: 8,
515 backgroundColor: colors.bgSecondary,
516 borderWidth: 1,
517 borderColor: colors.accentPurple,
518 },
519 mergedBannerText: {
520 fontSize: 13,
521 color: colors.accentPurple,
522 textAlign: 'center',
523 },
524 commentsHeader: {
525 paddingHorizontal: 16,
526 paddingVertical: 10,
527 borderBottomWidth: 1,
528 borderBottomColor: colors.border,
529 backgroundColor: colors.bgTertiary,
530 },
531 commentsHeaderText: {
532 fontSize: 12,
533 fontWeight: '600',
534 color: colors.textMuted,
535 textTransform: 'uppercase',
536 letterSpacing: 0.5,
537 },
538 commentCard: {
539 padding: 16,
540 borderBottomWidth: 1,
541 borderBottomColor: colors.border,
542 backgroundColor: colors.bgSecondary,
543 gap: 8,
544 },
545 commentHeader: {
546 flexDirection: 'row',
547 alignItems: 'center',
548 gap: 8,
549 },
550 commentAuthor: {
551 fontSize: 13,
552 fontWeight: '600',
553 color: colors.text,
554 },
555 aiTag: {
556 paddingHorizontal: 6,
557 paddingVertical: 2,
558 borderRadius: 10,
559 backgroundColor: colors.bgTertiary,
560 borderWidth: 1,
561 borderColor: colors.accentPurple,
562 },
563 aiTagText: {
564 fontSize: 10,
565 color: colors.accentPurple,
566 fontWeight: '600',
567 },
568 commentTime: {
569 fontSize: 12,
570 color: colors.textMuted,
571 marginLeft: 'auto',
572 },
573 fileRef: {
574 paddingHorizontal: 8,
575 paddingVertical: 4,
576 borderRadius: 4,
577 backgroundColor: colors.bgTertiary,
578 borderWidth: 1,
579 borderColor: colors.border,
580 alignSelf: 'flex-start',
581 },
582 fileRefText: {
583 fontSize: 11,
584 fontFamily: 'monospace',
585 color: colors.textMuted,
586 },
587 commentBody: {
588 fontSize: 14,
589 color: colors.text,
590 lineHeight: 20,
591 },
592 noComments: {
593 padding: 32,
594 alignItems: 'center',
595 },
596 noCommentsText: {
597 fontSize: 14,
598 color: colors.textMuted,
599 },
600 inputArea: {
601 padding: 12,
602 backgroundColor: colors.bgSecondary,
603 borderTopWidth: 1,
604 borderTopColor: colors.border,
605 },
606 inputRow: {
607 flexDirection: 'row',
608 alignItems: 'flex-end',
609 gap: 10,
610 },
611 commentInput: {
612 flex: 1,
613 backgroundColor: colors.bg,
614 borderWidth: 1,
615 borderColor: colors.border,
616 borderRadius: 8,
617 padding: 10,
618 fontSize: 14,
619 color: colors.text,
620 maxHeight: 100,
621 minHeight: 44,
622 },
623 sendButton: {
624 width: 40,
625 height: 40,
626 borderRadius: 20,
627 backgroundColor: colors.accentBlue,
628 alignItems: 'center',
629 justifyContent: 'center',
630 },
631 sendButtonDisabled: {
632 opacity: 0.4,
633 },
634 sendIcon: {
635 fontSize: 18,
636 color: colors.bg,
637 fontWeight: '700',
638 },
639});
Addedmobile/src/screens/PullsScreen.tsx+172−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 StyleSheet,
5 Text,
6 TouchableOpacity,
7 View,
8} from 'react-native';
9import { SafeAreaView } from 'react-native-safe-area-context';
10import type { NativeStackScreenProps } from '@react-navigation/native-stack';
11import { listPullRequests } from '../api/pulls';
12import type { PullRequest } from '../api/pulls';
13import { PullRow } from '../components/PullRow';
14import { LoadingSpinner } from '../components/LoadingSpinner';
15import { ErrorBanner } from '../components/ErrorBanner';
16import { colors } from '../theme/colors';
17import type { RepoStackParamList } from '../navigation/AppNavigator';
18
19type Props = NativeStackScreenProps<RepoStackParamList, 'Pulls'>;
20type FilterState = 'open' | 'closed';
21
22export function PullsScreen({ route, navigation }: Props): React.ReactElement {
23 const { owner, repo } = route.params;
24 const [filter, setFilter] = useState<FilterState>('open');
25 const [pulls, setPulls] = useState<PullRequest[]>([]);
26 const [isLoading, setIsLoading] = useState(true);
27 const [error, setError] = useState<string | null>(null);
28 const [page, setPage] = useState(1);
29 const [hasMore, setHasMore] = useState(true);
30
31 useEffect(() => {
32 let cancelled = false;
33 setIsLoading(true);
34 setError(null);
35
36 listPullRequests(owner, repo, filter, 1)
37 .then((data) => {
38 if (!cancelled) {
39 setPulls(data);
40 setPage(1);
41 setHasMore(data.length === 30);
42 }
43 })
44 .catch((err) => {
45 if (!cancelled) {
46 setError(err instanceof Error ? err.message : 'Failed to load pull requests');
47 }
48 })
49 .finally(() => {
50 if (!cancelled) setIsLoading(false);
51 });
52
53 return () => {
54 cancelled = true;
55 };
56 }, [owner, repo, filter]);
57
58 const loadMore = useCallback(() => {
59 if (!hasMore || isLoading) return;
60 const nextPage = page + 1;
61 listPullRequests(owner, repo, filter, nextPage)
62 .then((data) => {
63 setPulls((prev) => [...prev, ...data]);
64 setPage(nextPage);
65 setHasMore(data.length === 30);
66 })
67 .catch(() => {/* ignore */});
68 }, [owner, repo, filter, page, hasMore, isLoading]);
69
70 const handlePrPress = useCallback(
71 (pr: PullRequest) => {
72 navigation.navigate('PullDetail', { owner, repo, number: pr.number });
73 },
74 [navigation, owner, repo],
75 );
76
77 const renderPr = useCallback(
78 ({ item }: { item: PullRequest }) => <PullRow pr={item} onPress={handlePrPress} />,
79 [handlePrPress],
80 );
81
82 const keyExtractor = useCallback((item: PullRequest) => String(item.id), []);
83
84 const renderFooter = useCallback(
85 () => (hasMore ? <LoadingSpinner size="small" /> : null),
86 [hasMore],
87 );
88
89 if (isLoading && pulls.length === 0) return <LoadingSpinner fullScreen />;
90
91 return (
92 <SafeAreaView style={styles.safe} edges={['bottom']}>
93 <View style={styles.filterTabs}>
94 <TouchableOpacity
95 style={[styles.filterTab, filter === 'open' && styles.filterTabActive]}
96 onPress={() => setFilter('open')}
97 >
98 <Text style={[styles.filterTabText, filter === 'open' && styles.filterTabTextActive]}>
99 Open
100 </Text>
101 </TouchableOpacity>
102 <TouchableOpacity
103 style={[styles.filterTab, filter === 'closed' && styles.filterTabActive]}
104 onPress={() => setFilter('closed')}
105 >
106 <Text style={[styles.filterTabText, filter === 'closed' && styles.filterTabTextActive]}>
107 Closed / Merged
108 </Text>
109 </TouchableOpacity>
110 </View>
111
112 {error !== null && <ErrorBanner message={error} />}
113
114 <FlatList
115 data={pulls}
116 keyExtractor={keyExtractor}
117 renderItem={renderPr}
118 onEndReached={loadMore}
119 onEndReachedThreshold={0.3}
120 ListFooterComponent={renderFooter}
121 ListEmptyComponent={
122 <View style={styles.emptyState}>
123 <Text style={styles.emptyText}>No {filter} pull requests</Text>
124 </View>
125 }
126 style={styles.list}
127 />
128 </SafeAreaView>
129 );
130}
131
132const styles = StyleSheet.create({
133 safe: {
134 flex: 1,
135 backgroundColor: colors.bg,
136 },
137 filterTabs: {
138 flexDirection: 'row',
139 backgroundColor: colors.bgSecondary,
140 borderBottomWidth: 1,
141 borderBottomColor: colors.border,
142 },
143 filterTab: {
144 flex: 1,
145 paddingVertical: 12,
146 alignItems: 'center',
147 borderBottomWidth: 2,
148 borderBottomColor: 'transparent',
149 },
150 filterTabActive: {
151 borderBottomColor: colors.accentBlue,
152 },
153 filterTabText: {
154 fontSize: 14,
155 fontWeight: '500',
156 color: colors.textMuted,
157 },
158 filterTabTextActive: {
159 color: colors.text,
160 },
161 list: {
162 flex: 1,
163 },
164 emptyState: {
165 padding: 40,
166 alignItems: 'center',
167 },
168 emptyText: {
169 fontSize: 14,
170 color: colors.textMuted,
171 },
172});
Addedmobile/src/screens/RepoListScreen.tsx+180−0View fileUnifiedSplit
1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 StyleSheet,
5 Text,
6 TextInput,
7 View,
8} from 'react-native';
9import { SafeAreaView } from 'react-native-safe-area-context';
10import type { NativeStackScreenProps } from '@react-navigation/native-stack';
11import { useAuthStore } from '../store/authStore';
12import { listUserRepos } from '../api/repos';
13import type { Repository } from '../api/repos';
14import { RepoCard } from '../components/RepoCard';
15import { LoadingSpinner } from '../components/LoadingSpinner';
16import { ErrorBanner } from '../components/ErrorBanner';
17import { colors } from '../theme/colors';
18import type { RepoStackParamList } from '../navigation/AppNavigator';
19
20type Props = NativeStackScreenProps<RepoStackParamList, 'RepoList'>;
21
22export function RepoListScreen({ navigation }: Props): React.ReactElement {
23 const user = useAuthStore((s) => s.user);
24 const [repos, setRepos] = useState<Repository[]>([]);
25 const [filtered, setFiltered] = useState<Repository[]>([]);
26 const [query, setQuery] = useState('');
27 const [isLoading, setIsLoading] = useState(true);
28 const [error, setError] = useState<string | null>(null);
29 const [tick, setTick] = useState(0);
30
31 useEffect(() => {
32 if (!user) return;
33 let cancelled = false;
34 setIsLoading(true);
35 setError(null);
36
37 listUserRepos(user.username)
38 .then((data) => {
39 if (!cancelled) {
40 setRepos(data);
41 setFiltered(data);
42 }
43 })
44 .catch((err) => {
45 if (!cancelled) {
46 setError(err instanceof Error ? err.message : 'Failed to load repos');
47 }
48 })
49 .finally(() => {
50 if (!cancelled) setIsLoading(false);
51 });
52
53 return () => {
54 cancelled = true;
55 };
56 }, [user, tick]);
57
58 useEffect(() => {
59 const q = query.trim().toLowerCase();
60 if (!q) {
61 setFiltered(repos);
62 } else {
63 setFiltered(
64 repos.filter(
65 (r) =>
66 r.name.toLowerCase().includes(q) ||
67 (r.description ?? '').toLowerCase().includes(q),
68 ),
69 );
70 }
71 }, [query, repos]);
72
73 const retry = useCallback(() => setTick((t) => t + 1), []);
74
75 const handleRepoPress = useCallback(
76 (repo: Repository) => {
77 navigation.navigate('Repo', { owner: repo.ownerUsername, repo: repo.name });
78 },
79 [navigation],
80 );
81
82 const renderRepo = useCallback(
83 ({ item }: { item: Repository }) => (
84 <RepoCard repo={item} onPress={handleRepoPress} />
85 ),
86 [handleRepoPress],
87 );
88
89 const keyExtractor = useCallback((item: Repository) => String(item.id), []);
90
91 if (isLoading) return <LoadingSpinner fullScreen />;
92
93 return (
94 <SafeAreaView style={styles.safe} edges={['bottom']}>
95 <View style={styles.searchContainer}>
96 <TextInput
97 style={styles.searchInput}
98 value={query}
99 onChangeText={setQuery}
100 placeholder="Filter repositories..."
101 placeholderTextColor={colors.textMuted}
102 autoCapitalize="none"
103 autoCorrect={false}
104 clearButtonMode="while-editing"
105 />
106 </View>
107
108 {error !== null && <ErrorBanner message={error} onRetry={retry} />}
109
110 <FlatList
111 data={filtered}
112 keyExtractor={keyExtractor}
113 renderItem={renderRepo}
114 style={styles.list}
115 contentContainerStyle={styles.listContent}
116 ListEmptyComponent={
117 <View style={styles.emptyState}>
118 <Text style={styles.emptyText}>
119 {query ? 'No repositories match your filter.' : 'No repositories found.'}
120 </Text>
121 </View>
122 }
123 ListHeaderComponent={
124 <View style={styles.listHeader}>
125 <Text style={styles.listHeaderText}>
126 {filtered.length} {filtered.length === 1 ? 'repository' : 'repositories'}
127 </Text>
128 </View>
129 }
130 />
131 </SafeAreaView>
132 );
133}
134
135const styles = StyleSheet.create({
136 safe: {
137 flex: 1,
138 backgroundColor: colors.bg,
139 },
140 searchContainer: {
141 padding: 12,
142 backgroundColor: colors.bgSecondary,
143 borderBottomWidth: 1,
144 borderBottomColor: colors.border,
145 },
146 searchInput: {
147 backgroundColor: colors.bg,
148 borderWidth: 1,
149 borderColor: colors.border,
150 borderRadius: 8,
151 paddingHorizontal: 12,
152 paddingVertical: 10,
153 fontSize: 14,
154 color: colors.text,
155 },
156 list: {
157 flex: 1,
158 },
159 listContent: {
160 paddingTop: 8,
161 paddingBottom: 24,
162 },
163 listHeader: {
164 paddingHorizontal: 16,
165 paddingBottom: 8,
166 },
167 listHeaderText: {
168 fontSize: 12,
169 color: colors.textMuted,
170 },
171 emptyState: {
172 padding: 40,
173 alignItems: 'center',
174 },
175 emptyText: {
176 fontSize: 14,
177 color: colors.textMuted,
178 textAlign: 'center',
179 },
180});
Addedmobile/src/screens/RepoScreen.tsx+337−0View fileUnifiedSplit
1import React, { useCallback } from 'react';
2import {
3 FlatList,
4 ScrollView,
5 StyleSheet,
6 Text,
7 TouchableOpacity,
8 View,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import type { NativeStackScreenProps } from '@react-navigation/native-stack';
12import { useRepo, useTree } from '../hooks/useRepo';
13import { LoadingSpinner } from '../components/LoadingSpinner';
14import { ErrorBanner } from '../components/ErrorBanner';
15import { colors } from '../theme/colors';
16import type { RepoStackParamList } from '../navigation/AppNavigator';
17import type { TreeEntry } from '../api/repos';
18
19type Props = NativeStackScreenProps<RepoStackParamList, 'Repo'>;
20
21function FileRow({
22 entry,
23 onPress,
24}: {
25 entry: TreeEntry;
26 onPress: (entry: TreeEntry) => void;
27}): React.ReactElement {
28 const handlePress = useCallback(() => onPress(entry), [entry, onPress]);
29 const icon = entry.type === 'tree' ? '📁' : '📄';
30
31 return (
32 <TouchableOpacity style={styles.fileRow} onPress={handlePress} activeOpacity={0.75}>
33 <Text style={styles.fileIcon}>{icon}</Text>
34 <Text style={styles.fileName} numberOfLines={1}>
35 {entry.name}
36 </Text>
37 {entry.type === 'tree' && <Text style={styles.chevron}></Text>}
38 </TouchableOpacity>
39 );
40}
41
42export function RepoScreen({ route, navigation }: Props): React.ReactElement {
43 const { owner, repo: repoName } = route.params;
44 const { repo, isLoading: repoLoading, error: repoError, refresh: refreshRepo } = useRepo(owner, repoName);
45 const {
46 entries,
47 isLoading: treeLoading,
48 error: treeError,
49 refresh: refreshTree,
50 } = useTree(owner, repoName, repo?.defaultBranch ?? 'HEAD', '');
51
52 const isLoading = repoLoading || treeLoading;
53 const error = repoError ?? treeError;
54 const refresh = useCallback(() => {
55 refreshRepo();
56 refreshTree();
57 }, [refreshRepo, refreshTree]);
58
59 const handleEntryPress = useCallback(
60 (entry: TreeEntry) => {
61 if (entry.type === 'tree') {
62 navigation.navigate('FileViewer', {
63 owner,
64 repo: repoName,
65 ref: repo?.defaultBranch ?? 'HEAD',
66 path: entry.path,
67 });
68 } else {
69 navigation.navigate('FileViewer', {
70 owner,
71 repo: repoName,
72 ref: repo?.defaultBranch ?? 'HEAD',
73 path: entry.path,
74 });
75 }
76 },
77 [navigation, owner, repoName, repo],
78 );
79
80 const renderEntry = useCallback(
81 ({ item }: { item: TreeEntry }) => (
82 <FileRow entry={item} onPress={handleEntryPress} />
83 ),
84 [handleEntryPress],
85 );
86
87 const keyExtractor = useCallback((item: TreeEntry) => item.path, []);
88
89 if (isLoading) return <LoadingSpinner fullScreen />;
90
91 return (
92 <SafeAreaView style={styles.safe} edges={['bottom']}>
93 <ScrollView showsVerticalScrollIndicator={false}>
94 {error !== null && <ErrorBanner message={error} onRetry={refresh} />}
95
96 {repo !== null && (
97 <>
98 {/* Repo overview card */}
99 <View style={styles.overviewCard}>
100 {repo.description !== null && repo.description.length > 0 && (
101 <Text style={styles.description}>{repo.description}</Text>
102 )}
103 <View style={styles.metaRow}>
104 {repo.language !== null && (
105 <View style={styles.metaItem}>
106 <View style={styles.langDot} />
107 <Text style={styles.metaText}>{repo.language}</Text>
108 </View>
109 )}
110 <View style={styles.metaItem}>
111 <Text style={styles.metaIcon}></Text>
112 <Text style={styles.metaText}>{repo.starCount}</Text>
113 </View>
114 <View style={styles.metaItem}>
115 <Text style={styles.metaIcon}></Text>
116 <Text style={styles.metaText}>{repo.forkCount}</Text>
117 </View>
118 </View>
119 <View style={styles.branchRow}>
120 <Text style={styles.branchIcon}></Text>
121 <Text style={styles.branchText}>{repo.defaultBranch}</Text>
122 </View>
123 </View>
124
125 {/* Quick nav */}
126 <View style={styles.quickNav}>
127 <TouchableOpacity
128 style={styles.navButton}
129 onPress={() => navigation.navigate('Commits', { owner, repo: repoName })}
130 >
131 <Text style={styles.navIcon}></Text>
132 <Text style={styles.navLabel}>Commits</Text>
133 </TouchableOpacity>
134 <TouchableOpacity
135 style={styles.navButton}
136 onPress={() => navigation.navigate('Issues', { owner, repo: repoName })}
137 >
138 <Text style={styles.navIcon}></Text>
139 <Text style={styles.navLabel}>Issues</Text>
140 {repo.openIssueCount > 0 && (
141 <View style={styles.countBadge}>
142 <Text style={styles.countText}>{repo.openIssueCount}</Text>
143 </View>
144 )}
145 </TouchableOpacity>
146 <TouchableOpacity
147 style={styles.navButton}
148 onPress={() => navigation.navigate('Pulls', { owner, repo: repoName })}
149 >
150 <Text style={styles.navIcon}></Text>
151 <Text style={styles.navLabel}>PRs</Text>
152 </TouchableOpacity>
153 <TouchableOpacity
154 style={styles.navButton}
155 onPress={() => navigation.navigate('GateStatus', { owner, repo: repoName })}
156 >
157 <Text style={styles.navIcon}></Text>
158 <Text style={styles.navLabel}>Gates</Text>
159 </TouchableOpacity>
160 </View>
161 </>
162 )}
163
164 {/* File tree */}
165 <View style={styles.treeSection}>
166 <View style={styles.treeSectionHeader}>
167 <Text style={styles.treeSectionTitle}>Files</Text>
168 </View>
169 <View style={styles.treeContainer}>
170 <FlatList
171 data={entries}
172 keyExtractor={keyExtractor}
173 renderItem={renderEntry}
174 scrollEnabled={false}
175 ListEmptyComponent={
176 <View style={styles.emptyState}>
177 <Text style={styles.emptyText}>No files found</Text>
178 </View>
179 }
180 />
181 </View>
182 </View>
183 </ScrollView>
184 </SafeAreaView>
185 );
186}
187
188const styles = StyleSheet.create({
189 safe: {
190 flex: 1,
191 backgroundColor: colors.bg,
192 },
193 overviewCard: {
194 backgroundColor: colors.bgSecondary,
195 borderBottomWidth: 1,
196 borderBottomColor: colors.border,
197 padding: 16,
198 gap: 10,
199 },
200 description: {
201 fontSize: 14,
202 color: colors.text,
203 lineHeight: 20,
204 },
205 metaRow: {
206 flexDirection: 'row',
207 flexWrap: 'wrap',
208 gap: 12,
209 },
210 metaItem: {
211 flexDirection: 'row',
212 alignItems: 'center',
213 gap: 4,
214 },
215 langDot: {
216 width: 10,
217 height: 10,
218 borderRadius: 5,
219 backgroundColor: colors.accentPurple,
220 },
221 metaIcon: {
222 fontSize: 12,
223 color: colors.textMuted,
224 },
225 metaText: {
226 fontSize: 13,
227 color: colors.textMuted,
228 },
229 branchRow: {
230 flexDirection: 'row',
231 alignItems: 'center',
232 gap: 6,
233 paddingHorizontal: 10,
234 paddingVertical: 5,
235 backgroundColor: colors.bg,
236 borderRadius: 6,
237 borderWidth: 1,
238 borderColor: colors.border,
239 alignSelf: 'flex-start',
240 },
241 branchIcon: {
242 fontSize: 12,
243 color: colors.textMuted,
244 },
245 branchText: {
246 fontSize: 13,
247 fontFamily: 'monospace',
248 color: colors.text,
249 },
250 quickNav: {
251 flexDirection: 'row',
252 borderBottomWidth: 1,
253 borderBottomColor: colors.border,
254 backgroundColor: colors.bgSecondary,
255 },
256 navButton: {
257 flex: 1,
258 flexDirection: 'row',
259 alignItems: 'center',
260 justifyContent: 'center',
261 paddingVertical: 14,
262 gap: 6,
263 borderRightWidth: 1,
264 borderRightColor: colors.border,
265 },
266 navIcon: {
267 fontSize: 14,
268 color: colors.textMuted,
269 },
270 navLabel: {
271 fontSize: 13,
272 fontWeight: '500',
273 color: colors.text,
274 },
275 countBadge: {
276 paddingHorizontal: 6,
277 paddingVertical: 1,
278 borderRadius: 10,
279 backgroundColor: colors.accentYellow,
280 minWidth: 18,
281 alignItems: 'center',
282 },
283 countText: {
284 fontSize: 10,
285 fontWeight: '700',
286 color: colors.bg,
287 },
288 treeSection: {
289 marginTop: 8,
290 },
291 treeSectionHeader: {
292 paddingHorizontal: 16,
293 paddingVertical: 10,
294 },
295 treeSectionTitle: {
296 fontSize: 12,
297 fontWeight: '600',
298 color: colors.textMuted,
299 textTransform: 'uppercase',
300 letterSpacing: 0.5,
301 },
302 treeContainer: {
303 backgroundColor: colors.bgSecondary,
304 borderTopWidth: 1,
305 borderBottomWidth: 1,
306 borderColor: colors.border,
307 },
308 fileRow: {
309 flexDirection: 'row',
310 alignItems: 'center',
311 paddingVertical: 10,
312 paddingHorizontal: 16,
313 borderBottomWidth: 1,
314 borderBottomColor: colors.border,
315 gap: 10,
316 },
317 fileIcon: {
318 fontSize: 15,
319 },
320 fileName: {
321 flex: 1,
322 fontSize: 14,
323 color: colors.textLink,
324 },
325 chevron: {
326 fontSize: 18,
327 color: colors.textMuted,
328 },
329 emptyState: {
330 padding: 24,
331 alignItems: 'center',
332 },
333 emptyText: {
334 fontSize: 14,
335 color: colors.textMuted,
336 },
337});
Addedmobile/src/screens/SettingsScreen.tsx+347−0View fileUnifiedSplit
1import React, { useCallback, useState } from 'react';
2import {
3 Alert,
4 ScrollView,
5 StyleSheet,
6 Switch,
7 Text,
8 TextInput,
9 TouchableOpacity,
10 View,
11} from 'react-native';
12import { SafeAreaView } from 'react-native-safe-area-context';
13import { useAuth } from '../hooks/useAuth';
14import { useSettingsStore } from '../store/settingsStore';
15import { colors } from '../theme/colors';
16
17export function SettingsScreen(): React.ReactElement {
18 const { user, logout } = useAuth();
19 const { host, setHost, notificationsEnabled, setNotificationsEnabled } =
20 useSettingsStore();
21
22 const [hostInput, setHostInput] = useState(host);
23 const [hostSaved, setHostSaved] = useState(false);
24
25 const handleSaveHost = useCallback(() => {
26 const trimmed = hostInput.trim().replace(/\/$/, '');
27 if (!trimmed) return;
28 setHost(trimmed);
29 setHostSaved(true);
30 setTimeout(() => setHostSaved(false), 2000);
31 }, [hostInput, setHost]);
32
33 const handleLogout = useCallback(() => {
34 Alert.alert(
35 'Sign Out',
36 'Are you sure you want to sign out?',
37 [
38 { text: 'Cancel', style: 'cancel' },
39 {
40 text: 'Sign Out',
41 style: 'destructive',
42 onPress: () => logout(),
43 },
44 ],
45 );
46 }, [logout]);
47
48 return (
49 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
50 <ScrollView showsVerticalScrollIndicator={false}>
51 {/* Header */}
52 <View style={styles.pageHeader}>
53 <Text style={styles.pageTitle}>Settings</Text>
54 </View>
55
56 {/* Account section */}
57 <View style={styles.section}>
58 <Text style={styles.sectionTitle}>Account</Text>
59 <View style={styles.card}>
60 <View style={styles.accountRow}>
61 <View style={styles.avatar}>
62 <Text style={styles.avatarText}>
63 {user?.username.charAt(0).toUpperCase() ?? '?'}
64 </Text>
65 </View>
66 <View style={styles.accountInfo}>
67 <Text style={styles.username}>{user?.username ?? 'Unknown'}</Text>
68 <Text style={styles.email}>{user?.email ?? ''}</Text>
69 </View>
70 </View>
71 </View>
72 </View>
73
74 {/* Connection section */}
75 <View style={styles.section}>
76 <Text style={styles.sectionTitle}>Connection</Text>
77 <View style={styles.card}>
78 <View style={styles.formGroup}>
79 <Text style={styles.label}>Host URL</Text>
80 <View style={styles.inputRow}>
81 <TextInput
82 style={styles.input}
83 value={hostInput}
84 onChangeText={setHostInput}
85 placeholder="https://gluecron.com"
86 placeholderTextColor={colors.textMuted}
87 autoCapitalize="none"
88 autoCorrect={false}
89 keyboardType="url"
90 />
91 <TouchableOpacity
92 style={[styles.saveButton, hostSaved && styles.saveButtonSaved]}
93 onPress={handleSaveHost}
94 activeOpacity={0.8}
95 >
96 <Text style={styles.saveButtonText}>
97 {hostSaved ? '✓ Saved' : 'Save'}
98 </Text>
99 </TouchableOpacity>
100 </View>
101 <Text style={styles.hint}>
102 Current: {host}
103 </Text>
104 </View>
105 </View>
106 </View>
107
108 {/* Preferences section */}
109 <View style={styles.section}>
110 <Text style={styles.sectionTitle}>Preferences</Text>
111 <View style={styles.card}>
112 <View style={styles.toggleRow}>
113 <View>
114 <Text style={styles.toggleLabel}>Notifications</Text>
115 <Text style={styles.toggleDesc}>
116 Show notification badges
117 </Text>
118 </View>
119 <Switch
120 value={notificationsEnabled}
121 onValueChange={setNotificationsEnabled}
122 trackColor={{ false: colors.bgTertiary, true: colors.accent }}
123 thumbColor={colors.text}
124 />
125 </View>
126 </View>
127 </View>
128
129 {/* About section */}
130 <View style={styles.section}>
131 <Text style={styles.sectionTitle}>About</Text>
132 <View style={styles.card}>
133 <View style={styles.aboutRow}>
134 <Text style={styles.aboutLabel}>App Version</Text>
135 <Text style={styles.aboutValue}>1.0.0</Text>
136 </View>
137 <View style={[styles.aboutRow, styles.aboutRowBorder]}>
138 <Text style={styles.aboutLabel}>Platform</Text>
139 <Text style={styles.aboutValue}>Gluecron Mobile</Text>
140 </View>
141 <View style={[styles.aboutRow, styles.aboutRowBorder]}>
142 <Text style={styles.aboutLabel}>Connected to</Text>
143 <Text style={styles.aboutValue} numberOfLines={1}>
144 {host}
145 </Text>
146 </View>
147 </View>
148 </View>
149
150 {/* Danger zone */}
151 <View style={styles.section}>
152 <TouchableOpacity
153 style={styles.logoutButton}
154 onPress={handleLogout}
155 activeOpacity={0.8}
156 >
157 <Text style={styles.logoutText}>Sign Out</Text>
158 </TouchableOpacity>
159 </View>
160
161 <View style={styles.footer}>
162 <Text style={styles.footerText}>
163 Gluecron — AI-native code intelligence platform
164 </Text>
165 <Text style={styles.footerText}>gluecron.com</Text>
166 </View>
167 </ScrollView>
168 </SafeAreaView>
169 );
170}
171
172const styles = StyleSheet.create({
173 safe: {
174 flex: 1,
175 backgroundColor: colors.bg,
176 },
177 pageHeader: {
178 paddingHorizontal: 16,
179 paddingVertical: 16,
180 backgroundColor: colors.bgSecondary,
181 borderBottomWidth: 1,
182 borderBottomColor: colors.border,
183 },
184 pageTitle: {
185 fontSize: 20,
186 fontWeight: '700',
187 color: colors.text,
188 },
189 section: {
190 marginTop: 24,
191 paddingHorizontal: 16,
192 gap: 8,
193 },
194 sectionTitle: {
195 fontSize: 12,
196 fontWeight: '600',
197 color: colors.textMuted,
198 textTransform: 'uppercase',
199 letterSpacing: 0.5,
200 marginBottom: 4,
201 },
202 card: {
203 backgroundColor: colors.bgSecondary,
204 borderRadius: 10,
205 borderWidth: 1,
206 borderColor: colors.border,
207 overflow: 'hidden',
208 },
209 accountRow: {
210 flexDirection: 'row',
211 alignItems: 'center',
212 padding: 14,
213 gap: 12,
214 },
215 avatar: {
216 width: 48,
217 height: 48,
218 borderRadius: 24,
219 backgroundColor: colors.accentBlue,
220 alignItems: 'center',
221 justifyContent: 'center',
222 },
223 avatarText: {
224 fontSize: 20,
225 fontWeight: '700',
226 color: colors.bg,
227 },
228 accountInfo: {
229 gap: 3,
230 },
231 username: {
232 fontSize: 16,
233 fontWeight: '600',
234 color: colors.text,
235 },
236 email: {
237 fontSize: 13,
238 color: colors.textMuted,
239 },
240 formGroup: {
241 padding: 14,
242 gap: 8,
243 },
244 label: {
245 fontSize: 12,
246 fontWeight: '500',
247 color: colors.textMuted,
248 textTransform: 'uppercase',
249 letterSpacing: 0.4,
250 },
251 inputRow: {
252 flexDirection: 'row',
253 gap: 8,
254 alignItems: 'center',
255 },
256 input: {
257 flex: 1,
258 backgroundColor: colors.bg,
259 borderWidth: 1,
260 borderColor: colors.border,
261 borderRadius: 8,
262 paddingHorizontal: 12,
263 paddingVertical: 10,
264 fontSize: 14,
265 color: colors.text,
266 },
267 saveButton: {
268 paddingHorizontal: 14,
269 paddingVertical: 10,
270 backgroundColor: colors.bgTertiary,
271 borderRadius: 8,
272 borderWidth: 1,
273 borderColor: colors.border,
274 },
275 saveButtonSaved: {
276 borderColor: colors.accent,
277 },
278 saveButtonText: {
279 fontSize: 13,
280 fontWeight: '600',
281 color: colors.textLink,
282 },
283 hint: {
284 fontSize: 12,
285 color: colors.textMuted,
286 },
287 toggleRow: {
288 flexDirection: 'row',
289 alignItems: 'center',
290 justifyContent: 'space-between',
291 padding: 14,
292 },
293 toggleLabel: {
294 fontSize: 15,
295 fontWeight: '500',
296 color: colors.text,
297 },
298 toggleDesc: {
299 fontSize: 12,
300 color: colors.textMuted,
301 marginTop: 2,
302 },
303 aboutRow: {
304 flexDirection: 'row',
305 alignItems: 'center',
306 justifyContent: 'space-between',
307 paddingVertical: 12,
308 paddingHorizontal: 14,
309 },
310 aboutRowBorder: {
311 borderTopWidth: 1,
312 borderTopColor: colors.border,
313 },
314 aboutLabel: {
315 fontSize: 14,
316 color: colors.textMuted,
317 },
318 aboutValue: {
319 fontSize: 14,
320 color: colors.text,
321 maxWidth: '60%',
322 textAlign: 'right',
323 },
324 logoutButton: {
325 backgroundColor: colors.bgSecondary,
326 borderRadius: 10,
327 borderWidth: 1,
328 borderColor: colors.accentRed,
329 paddingVertical: 14,
330 alignItems: 'center',
331 },
332 logoutText: {
333 fontSize: 15,
334 fontWeight: '600',
335 color: colors.accentRed,
336 },
337 footer: {
338 padding: 32,
339 alignItems: 'center',
340 gap: 4,
341 },
342 footerText: {
343 fontSize: 12,
344 color: colors.textMuted,
345 textAlign: 'center',
346 },
347});
0348