diff --git a/.gitignore b/.gitignore index 6c9dcee..4a7b4a6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,6 @@ dist/ build/ *.tsbuildinfo -# Environment variables -.env -.env.local -.env.*.local - # IDE .vscode/ .idea/ diff --git a/backend-nest/VALIDATORS_REFERENCE.md b/backend-nest/VALIDATORS_REFERENCE.md deleted file mode 100644 index ffc5904..0000000 --- a/backend-nest/VALIDATORS_REFERENCE.md +++ /dev/null @@ -1,219 +0,0 @@ -# Список декораторов валидации для CreateTaskDto - -## Для строковых полей (title, description, category) - -### 1. **MinLength** - минимальная длина строки -```typescript -@MinLength(3, { message: 'Title must be at least 3 characters long' }) -title: string; -``` - -### 2. **MaxLength** - максимальная длина строки -```typescript -@MaxLength(100, { message: 'Title must not exceed 100 characters' }) -title: string; -``` - -### 3. **Length** - точная длина или диапазон -```typescript -@Length(3, 100, { message: 'Title must be between 3 and 100 characters' }) -title: string; -``` - -### 4. **Matches** - проверка по регулярному выражению -```typescript -@Matches(/^[a-zA-Z0-9\s]+$/, { message: 'Title can only contain letters, numbers and spaces' }) -title: string; -``` - -### 5. **IsAlphanumeric** - только буквы и цифры -```typescript -@IsAlphanumeric() -category: string; -``` - -### 6. **IsAlpha** - только буквы -```typescript -@IsAlpha() -category: string; -``` - -### 7. **IsUppercase** / **IsLowercase** - регистр -```typescript -@IsUppercase() -category: string; -``` - -### 8. **Contains** - содержит подстроку -```typescript -@Contains('task', { message: 'Title must contain "task"' }) -title: string; -``` - -### 9. **NotContains** - не содержит подстроку -```typescript -@NotContains('spam', { message: 'Title cannot contain "spam"' }) -title: string; -``` - -### 10. **IsUrl** - проверка URL -```typescript -@IsUrl() -attachmentUrl: string; -``` - -### 11. **IsEmail** - проверка email -```typescript -@IsEmail() -assigneeEmail: string; -``` - -### 12. **IsUUID** - проверка UUID -```typescript -@IsUUID() -userId: string; -``` - -### 13. **IsOptional** - опциональное поле -```typescript -@IsOptional() -@IsString() -description?: string; -``` - -### 14. **IsDefined** - поле должно быть определено -```typescript -@IsDefined() -title: string; -``` - -## Для enum полей (priority) - -### 15. **IsIn** - значение из списка (альтернатива IsEnum) -```typescript -@IsIn(['low', 'medium', 'high']) -priority: string; -``` - -## Для числовых полей (если добавите) - -### 16. **IsNumber** - проверка числа -```typescript -@IsNumber() -estimatedHours: number; -``` - -### 17. **IsInt** - целое число -```typescript -@IsInt() -order: number; -``` - -### 18. **Min** - минимальное значение -```typescript -@Min(0, { message: 'Estimated hours must be at least 0' }) -estimatedHours: number; -``` - -### 19. **Max** - максимальное значение -```typescript -@Max(100, { message: 'Estimated hours must not exceed 100' }) -estimatedHours: number; -``` - -### 20. **IsPositive** - положительное число -```typescript -@IsPositive() -estimatedHours: number; -``` - -### 21. **IsNegative** - отрицательное число -```typescript -@IsNegative() -penalty: number; -``` - -## Для дат (если добавите) - -### 22. **IsDate** - проверка даты -```typescript -@IsDate() -dueDate: Date; -``` - -### 23. **IsDateString** - строка в формате даты -```typescript -@IsDateString() -dueDate: string; -``` - -### 24. **MinDate** - минимальная дата -```typescript -@MinDate(new Date(), { message: 'Due date must be in the future' }) -dueDate: Date; -``` - -### 25. **MaxDate** - максимальная дата -```typescript -@MaxDate(new Date('2025-12-31'), { message: 'Due date must be before 2026' }) -dueDate: Date; -``` - -## Для массивов (если добавите) - -### 26. **IsArray** - проверка массива -```typescript -@IsArray() -tags: string[]; -``` - -### 27. **ArrayMinSize** - минимальный размер массива -```typescript -@ArrayMinSize(1, { message: 'At least one tag is required' }) -tags: string[]; -``` - -### 28. **ArrayMaxSize** - максимальный размер массива -```typescript -@ArrayMaxSize(10, { message: 'Maximum 10 tags allowed' }) -tags: string[]; -``` - -### 29. **ArrayNotEmpty** - массив не пустой -```typescript -@ArrayNotEmpty() -tags: string[]; -``` - -## Для булевых значений (если добавите) - -### 30. **IsBoolean** - проверка булева значения -```typescript -@IsBoolean() -isCompleted: boolean; -``` - -## Комбинированные валидаторы - -### 31. **ValidateIf** - условная валидация -```typescript -@ValidateIf(o => o.priority === 'high') -@IsNotEmpty() -urgentReason: string; -``` - -### 32. **ValidateNested** - валидация вложенных объектов -```typescript -@ValidateNested() -@Type(() => AssigneeDto) -assignee: AssigneeDto; -``` - -### 33. **IsObject** - проверка объекта -```typescript -@IsObject() -metadata: Record; -``` - - - diff --git a/mobile-expo/.env b/mobile-expo/.env new file mode 100644 index 0000000..d56b45e --- /dev/null +++ b/mobile-expo/.env @@ -0,0 +1,2 @@ +NEST_BASE_URL=http://localhost:4200/api/v1 +EXPRESS_BASE_URL=http://localhost:4300/api/v1 \ No newline at end of file diff --git a/mobile-expo/.gitignore b/mobile-expo/.gitignore new file mode 100644 index 0000000..2e94515 --- /dev/null +++ b/mobile-expo/.gitignore @@ -0,0 +1,38 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/mobile-expo/App.tsx b/mobile-expo/App.tsx new file mode 100644 index 0000000..4293eae --- /dev/null +++ b/mobile-expo/App.tsx @@ -0,0 +1,13 @@ +import { ErrorBoundary } from './src/components'; +import { AppNavigator } from './src/navigation'; +import { AppProvider } from './src/providers'; + +export default function App() { + return ( + + + + + + ); +} diff --git a/mobile-expo/app.json b/mobile-expo/app.json new file mode 100644 index 0000000..62b8dfa --- /dev/null +++ b/mobile-expo/app.json @@ -0,0 +1,30 @@ +{ + "expo": { + "name": "mobile-expo", + "slug": "mobile-expo", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/mobile-expo/assets/adaptive-icon.png b/mobile-expo/assets/adaptive-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/mobile-expo/assets/adaptive-icon.png differ diff --git a/mobile-expo/assets/favicon.png b/mobile-expo/assets/favicon.png new file mode 100644 index 0000000..e75f697 Binary files /dev/null and b/mobile-expo/assets/favicon.png differ diff --git a/mobile-expo/assets/icon.png b/mobile-expo/assets/icon.png new file mode 100644 index 0000000..a0b1526 Binary files /dev/null and b/mobile-expo/assets/icon.png differ diff --git a/mobile-expo/assets/splash-icon.png b/mobile-expo/assets/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/mobile-expo/assets/splash-icon.png differ diff --git a/mobile-expo/babel.config.js b/mobile-expo/babel.config.js new file mode 100644 index 0000000..9ccbb40 --- /dev/null +++ b/mobile-expo/babel.config.js @@ -0,0 +1,33 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'module-resolver', + { + root: ['./src'], + alias: { + '@': './src', + }, + extensions: [ + '.ios.ts', + '.android.ts', + '.native.ts', + '.web.ts', + '.ts', + '.ios.tsx', + '.android.tsx', + '.native.tsx', + '.web.tsx', + '.tsx', + '.jsx', + '.js', + '.json', + ], + }, + ], + ], + }; +}; + diff --git a/mobile-expo/index.ts b/mobile-expo/index.ts new file mode 100644 index 0000000..1d6e981 --- /dev/null +++ b/mobile-expo/index.ts @@ -0,0 +1,8 @@ +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/mobile-expo/package.json b/mobile-expo/package.json new file mode 100644 index 0000000..56d23f2 --- /dev/null +++ b/mobile-expo/package.json @@ -0,0 +1,47 @@ +{ + "name": "mobile-expo", + "version": "1.0.0", + "main": "index.ts", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@gorhom/bottom-sheet": "^5.2.8", + "@gorhom/portal": "^1.0.14", + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-navigation/bottom-tabs": "^7.8.11", + "@react-navigation/core": "^7.13.5", + "@react-navigation/native": "^7.1.24", + "@react-navigation/native-stack": "^7.8.5", + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.2", + "date-fns": "^4.1.0", + "expo": "~54.0.30", + "expo-status-bar": "~3.0.9", + "react": "19.1.0", + "react-error-boundary": "^6.0.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-keyboard-aware-scroll-view": "^0.9.5", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-unistyles": "^3.0.19", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@types/react": "~19.1.0", + "@types/react-native": "^0.72.8", + "babel-plugin-module-resolver": "^5.0.2", + "babel-preset-expo": "^54.0.9", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.7.4", + "typescript": "~5.9.2" + }, + "private": true +} diff --git a/mobile-expo/src/components/common/CustomHeader/index.tsx b/mobile-expo/src/components/common/CustomHeader/index.tsx new file mode 100644 index 0000000..d4f96c5 --- /dev/null +++ b/mobile-expo/src/components/common/CustomHeader/index.tsx @@ -0,0 +1,54 @@ +import { View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + CustomHeaderButton, + TCustomHeaderButton, +} from './ui/CustomHeaderButton'; +import { UIText } from '../UIComponents'; +import { useTheme } from '@/hooks'; + +interface CustomHeaderProps { + title?: string; + leftButton?: TCustomHeaderButton; + rightButton?: TCustomHeaderButton; +} + +export const CustomHeader = ({ + title, + leftButton, + rightButton, +}: CustomHeaderProps) => { + const insets = useSafeAreaInsets(); + const { theme } = useTheme(); + + return ( + + + + {leftButton && } + + + + {title && } + + + + {rightButton && } + + + + ); +}; diff --git a/mobile-expo/src/components/common/CustomHeader/ui/CustomHeaderButton.tsx b/mobile-expo/src/components/common/CustomHeader/ui/CustomHeaderButton.tsx new file mode 100644 index 0000000..db16db9 --- /dev/null +++ b/mobile-expo/src/components/common/CustomHeader/ui/CustomHeaderButton.tsx @@ -0,0 +1,31 @@ +import { Ionicons } from '@expo/vector-icons'; +import type { ComponentProps } from 'react'; +import { TouchableOpacity } from 'react-native'; + +type IoniconsName = ComponentProps['name']; + +export type TCustomHeaderButton = { + name: IoniconsName; + onPress?: () => void; + size?: number; + color?: string; +}; + +interface CustomHeaderButtonProps extends TCustomHeaderButton {} + +export const CustomHeaderButton = ({ + name, + onPress = () => {}, + size = 30, + color = '#000', +}: CustomHeaderButtonProps) => { + return ( + + + + ); +}; diff --git a/mobile-expo/src/components/common/ErrorBoundary.tsx b/mobile-expo/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..33af3f4 --- /dev/null +++ b/mobile-expo/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,60 @@ +import { Component, ReactNode } from 'react'; +import { View, StyleSheet, Text, TouchableOpacity } from 'react-native'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: any) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + Something went wrong + {this.state.error?.message} + + Try again + + + ); + } + + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, +}); diff --git a/mobile-expo/src/components/common/ScreenWrapper.tsx b/mobile-expo/src/components/common/ScreenWrapper.tsx new file mode 100644 index 0000000..f43bd60 --- /dev/null +++ b/mobile-expo/src/components/common/ScreenWrapper.tsx @@ -0,0 +1,87 @@ +import { useTheme } from '@/hooks'; +import { ReactNode } from 'react'; +import { + View, + StyleSheet, + ScrollView, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface ScreenWrapperProps { + children: ReactNode; + safeAreaTop?: boolean; + safeAreaBottom?: boolean; + scrollable?: boolean; + keyboardAvoiding?: boolean; + style?: View['props']['style']; + contentStyle?: View['props']['style']; +} + +export const ScreenWrapper = ({ + children, + safeAreaTop = false, + safeAreaBottom = false, + scrollable = false, + keyboardAvoiding = false, + style, + contentStyle, +}: ScreenWrapperProps) => { + const insets = useSafeAreaInsets(); + const { theme } = useTheme(); + + const containerStyle = [ + styles.container, + { + paddingTop: safeAreaTop ? insets.top : 0, + paddingBottom: safeAreaBottom ? insets.bottom : 0, + backgroundColor: theme.colors.background.background, + }, + style, + ]; + + const content = scrollable ? ( + + {children} + + ) : ( + {children} + ); + + if (keyboardAvoiding) { + return ( + + {content} + + ); + } + + return {content}; +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + padding: 16, + }, + scrollView: { + flex: 1, + padding: 16, + }, + scrollContent: { + flexGrow: 1, + }, +}); diff --git a/mobile-expo/src/components/common/UIComponents/UIBox/index.tsx b/mobile-expo/src/components/common/UIComponents/UIBox/index.tsx new file mode 100644 index 0000000..4b29dd8 --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UIBox/index.tsx @@ -0,0 +1,27 @@ +import { useTheme } from '@/hooks'; +import { spacing } from '@/styles'; +import { ReactNode } from 'react'; +import { StyleSheet, View, ViewStyle, StyleProp } from 'react-native'; + +interface UIBoxProps { + children: ReactNode; + style?: StyleProp; +} + +export const UIBox = ({ children, style}: UIBoxProps) => { + const { theme } = useTheme(); + + const boxStyles = { + backgroundColor: theme.colors.base.primaryLight, + }; + + return {children}; +}; + +const styles = StyleSheet.create({ + container: { + padding: spacing.md, + borderRadius: spacing.md, + gap: spacing.md, + }, +}); diff --git a/mobile-expo/src/components/common/UIComponents/UIButton/index.tsx b/mobile-expo/src/components/common/UIComponents/UIButton/index.tsx new file mode 100644 index 0000000..a578e21 --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UIButton/index.tsx @@ -0,0 +1,69 @@ +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + TouchableOpacityProps, +} from 'react-native'; +import { EnumUIButtonVariants } from './types'; +import { getUIButtonStyles, getUIButtonTextColor } from './utils'; +import { useTheme } from '@/hooks'; +import { UIText } from '../UIText'; +import { TTextColor } from '@/types'; +import { spacing } from '@/styles'; + +interface UIButtonProps extends Omit { + title: string; + onPress: () => void; + variant?: EnumUIButtonVariants; + isLoading?: boolean; + isDisabled?: boolean; +} + +export const UIButton = ({ + title, + onPress, + variant = EnumUIButtonVariants.PRIMARY, + isLoading = false, + isDisabled = false, + ...props +}: UIButtonProps) => { + const { theme } = useTheme(); + + const indicatorColor = getUIButtonTextColor(variant, theme); + const textColor: TTextColor = + variant === EnumUIButtonVariants.PRIMARY ? 'primary' : 'secondary'; + const buttonStyles = getUIButtonStyles(variant, theme); + + const buttonContent = () => { + return isLoading ? ( + + ) : ( + + ); + }; + + return ( + + {buttonContent()} + + ); +}; + +const styles = StyleSheet.create({ + button: { + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + }, + disabled: { + opacity: 0.5, + }, +}); diff --git a/mobile-expo/src/components/common/UIComponents/UIButton/types.ts b/mobile-expo/src/components/common/UIComponents/UIButton/types.ts new file mode 100644 index 0000000..8e59540 --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UIButton/types.ts @@ -0,0 +1,5 @@ +export const enum EnumUIButtonVariants { + PRIMARY = 'primary', + SECONDARY = 'secondary', + OUTLINE = 'outline', +} diff --git a/mobile-expo/src/components/common/UIComponents/UIButton/utils.ts b/mobile-expo/src/components/common/UIComponents/UIButton/utils.ts new file mode 100644 index 0000000..8de267c --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UIButton/utils.ts @@ -0,0 +1,35 @@ +import { Theme } from '@/types'; +import { EnumUIButtonVariants } from './types'; +import { ViewStyle } from 'react-native'; + +export const getUIButtonTextColor = ( + variant: EnumUIButtonVariants, + theme: Theme +): string => { + switch (variant) { + case EnumUIButtonVariants.PRIMARY: + return theme.colors.text.primary; + case EnumUIButtonVariants.SECONDARY: + return theme.colors.text.secondary; + case EnumUIButtonVariants.OUTLINE: + return theme.colors.text.secondary; + default: + return theme.colors.text.primary; + } +}; + +export const getUIButtonStyles = ( + variant: EnumUIButtonVariants, + theme: Theme +): ViewStyle => { + switch (variant) { + case EnumUIButtonVariants.PRIMARY: + return { backgroundColor: theme.colors.base.primaryDark }; + case EnumUIButtonVariants.SECONDARY: + return { backgroundColor: theme.colors.base.primary }; + case EnumUIButtonVariants.OUTLINE: + return { backgroundColor: theme.colors.base.primaryLight }; + default: + return { backgroundColor: theme.colors.base.primaryDark }; + } +}; diff --git a/mobile-expo/src/components/common/UIComponents/UIInput/index.tsx b/mobile-expo/src/components/common/UIComponents/UIInput/index.tsx new file mode 100644 index 0000000..b31dc43 --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UIInput/index.tsx @@ -0,0 +1,22 @@ +import { StyleSheet, TextInput, TextInputProps, View } from 'react-native'; +import { UIText } from '../UIText'; +import { spacing } from '@/styles'; + +interface UIInputProps extends Omit { + label?: string; +} + +export const UIInput = ({ label, ...props }: UIInputProps) => { + return ( + + {label && } + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: spacing.sm, + }, +}); diff --git a/mobile-expo/src/components/common/UIComponents/UISwitcher/index.tsx b/mobile-expo/src/components/common/UIComponents/UISwitcher/index.tsx new file mode 100644 index 0000000..5ca1c1f --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UISwitcher/index.tsx @@ -0,0 +1,19 @@ +import { useTheme } from '@/hooks'; +import { Switch, SwitchProps } from 'react-native'; + +interface UISwitcherProps extends SwitchProps {} + +export const UISwitcher = (props: UISwitcherProps) => { + const { theme } = useTheme(); + + return ( + + ); +}; diff --git a/mobile-expo/src/components/common/UIComponents/UIText/index.tsx b/mobile-expo/src/components/common/UIComponents/UIText/index.tsx new file mode 100644 index 0000000..9523605 --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/UIText/index.tsx @@ -0,0 +1,43 @@ +import { useTheme } from '@/hooks'; +import { TFontSize, TFontWeight, typography } from '@/styles'; +import { TTextColor } from '@/types'; +import { StyleSheet, Text, TextProps } from 'react-native'; + +interface UITextProps extends Omit { + text: string + size?: TFontSize; + weight?: TFontWeight; + color?: TTextColor; +} + +export const UIText = ({ + text, + size = 'md', + weight = 'regular', + color = 'primary', + ...props +}: UITextProps) => { + const { theme } = useTheme(); + + const textStyles = [ + styles.text, + { + fontSize: typography.fontSize[size], + fontWeight: typography.fontWeight[weight], + color: theme.colors.text[color], + }, + ]; + + return ( + + {text} + + ); +}; + +const styles = StyleSheet.create({ + text: { + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.regular, + }, +}); diff --git a/mobile-expo/src/components/common/UIComponents/index.ts b/mobile-expo/src/components/common/UIComponents/index.ts new file mode 100644 index 0000000..71ee48c --- /dev/null +++ b/mobile-expo/src/components/common/UIComponents/index.ts @@ -0,0 +1,5 @@ +export * from './UIText'; +export * from './UIButton'; +export * from './UIText'; +export * from './UIBox'; +export * from './UISwitcher'; diff --git a/mobile-expo/src/components/common/index.ts b/mobile-expo/src/components/common/index.ts new file mode 100644 index 0000000..14d98ca --- /dev/null +++ b/mobile-expo/src/components/common/index.ts @@ -0,0 +1,4 @@ +export * from './ScreenWrapper'; +export * from './ErrorBoundary'; +export * from './CustomHeader'; +export * from './UIComponents'; diff --git a/mobile-expo/src/components/index.ts b/mobile-expo/src/components/index.ts new file mode 100644 index 0000000..7cdf99b --- /dev/null +++ b/mobile-expo/src/components/index.ts @@ -0,0 +1,2 @@ +export * from './common'; +export * from './tasks'; diff --git a/mobile-expo/src/components/tasks/TaskBlocked/index.tsx b/mobile-expo/src/components/tasks/TaskBlocked/index.tsx new file mode 100644 index 0000000..c41a042 --- /dev/null +++ b/mobile-expo/src/components/tasks/TaskBlocked/index.tsx @@ -0,0 +1,16 @@ +import { useTheme } from '@/hooks'; +import { Octicons } from '@expo/vector-icons'; +import { FC } from 'react'; +import { View } from 'react-native'; + +interface TaskBlockedProps {} + +export const TaskBlocked: FC = ({}) => { + const { theme } = useTheme(); + + return ( + + + + ); +}; diff --git a/mobile-expo/src/components/tasks/TaskCard/index.tsx b/mobile-expo/src/components/tasks/TaskCard/index.tsx new file mode 100644 index 0000000..7e715b0 --- /dev/null +++ b/mobile-expo/src/components/tasks/TaskCard/index.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { UIBox, UIText } from '../../common'; +import { TTask } from '@/types'; +import { TaskPriority } from '../TaskPriority'; +import { StyleSheet, View } from 'react-native'; +import { spacing } from '@/styles'; +import { TaskStatus } from '../TaskStatus'; +import { TaskBlocked } from '../TaskBlocked'; + +interface TaskCardProps { + task: TTask; +} + +export const TaskCard: FC = ({ task }) => { + const { title, priority, status, isBlocked } = task; + + return ( + + + + + + {isBlocked && } + + + ); +}; + +const styles = StyleSheet.create({ + container: {}, + states: { + flexDirection: 'row', + gap: spacing.lg, + }, +}); diff --git a/mobile-expo/src/components/tasks/TaskPriority/index.tsx b/mobile-expo/src/components/tasks/TaskPriority/index.tsx new file mode 100644 index 0000000..45a7696 --- /dev/null +++ b/mobile-expo/src/components/tasks/TaskPriority/index.tsx @@ -0,0 +1,37 @@ +import { EnumTaskPriority } from '@/types'; +import { FC } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { getTaskPriorityIcon } from './utils'; +import { useTheme } from '@/hooks'; +import { UIText } from '@/components/common'; +import FontAwesome5 from '@expo/vector-icons/FontAwesome5'; +import { spacing } from '@/styles'; +import { capitalizeString } from '@/utils'; + +interface TaskPriorityProps { + priority: EnumTaskPriority; + isLabelShown?: boolean; +} + +export const TaskPriority: FC = ({ + priority, + isLabelShown = true, +}) => { + const { theme } = useTheme(); + const { name, color } = getTaskPriorityIcon(priority, theme); + + return ( + + + {isLabelShown && } + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, +}); diff --git a/mobile-expo/src/components/tasks/TaskPriority/utils.ts b/mobile-expo/src/components/tasks/TaskPriority/utils.ts new file mode 100644 index 0000000..d434a23 --- /dev/null +++ b/mobile-expo/src/components/tasks/TaskPriority/utils.ts @@ -0,0 +1,29 @@ +import { EnumTaskPriority, Theme } from '@/types'; + +export const getTaskPriorityIcon = ( + priority: EnumTaskPriority, + theme: Theme +) => { + switch (priority) { + case EnumTaskPriority.HIGH: + return { + name: 'angle-double-up', + color: theme.colors.priority.high, + }; + case EnumTaskPriority.MEDIUM: + return { + name: 'angle-up', + color: theme.colors.priority.medium, + }; + case EnumTaskPriority.LOW: + return { + name: 'angle-down', + color: theme.colors.priority.low, + }; + default: + return { + name: 'question', + color: theme.colors.priority.medium, + }; + } +}; diff --git a/mobile-expo/src/components/tasks/TaskStatus/index.tsx b/mobile-expo/src/components/tasks/TaskStatus/index.tsx new file mode 100644 index 0000000..2c51bec --- /dev/null +++ b/mobile-expo/src/components/tasks/TaskStatus/index.tsx @@ -0,0 +1,34 @@ +import { UIText } from '@/components/common'; +import { spacing } from '@/styles'; +import { EnumTaskStatus } from '@/types'; +import { capitalizeString } from '@/utils'; +import { FC } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { getTaskStatusIcon } from './utils'; +import { useTheme } from '@/hooks'; +import { Entypo } from '@expo/vector-icons'; + +interface TaskStatusProps { + status: EnumTaskStatus; + isLabelShown?: boolean; +} + +export const TaskStatus: FC = ({ status, isLabelShown = true }) => { + const { theme } = useTheme(); + const { name, color } = getTaskStatusIcon(status, theme); + + return ( + + + {isLabelShown && } + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, +}); diff --git a/mobile-expo/src/components/tasks/TaskStatus/utils.ts b/mobile-expo/src/components/tasks/TaskStatus/utils.ts new file mode 100644 index 0000000..61caa8d --- /dev/null +++ b/mobile-expo/src/components/tasks/TaskStatus/utils.ts @@ -0,0 +1,26 @@ +import { EnumTaskStatus, Theme } from '@/types'; + +export const getTaskStatusIcon = (status: EnumTaskStatus, theme: Theme) => { + switch (status) { + case EnumTaskStatus.TODO: + return { + name: 'progress-one', + color: theme.colors.status.todo, + }; + case EnumTaskStatus.IN_PROGRESS: + return { + name: 'progress-two', + color: theme.colors.status.inProgress, + }; + case EnumTaskStatus.DONE: + return { + name: 'progress-full', + color: theme.colors.status.done, + }; + default: + return { + name: 'progress-one', + color: theme.colors.status.inProgress, + }; + } +}; diff --git a/mobile-expo/src/components/tasks/index.ts b/mobile-expo/src/components/tasks/index.ts new file mode 100644 index 0000000..424ed01 --- /dev/null +++ b/mobile-expo/src/components/tasks/index.ts @@ -0,0 +1,4 @@ +export * from './TaskCard'; +export * from './TaskPriority'; +export * from './TaskStatus'; +export * from './TaskBlocked'; diff --git a/mobile-expo/src/constants/index.ts b/mobile-expo/src/constants/index.ts new file mode 100644 index 0000000..b69a70d --- /dev/null +++ b/mobile-expo/src/constants/index.ts @@ -0,0 +1,4 @@ +export const CONSTANTS = { + DEFAULT_URL: 'http://localhost:4200/api/v1', + DEFAULT_STALE_TIME: 1000 * 60 * 5, // 5 min +} \ No newline at end of file diff --git a/mobile-expo/src/hooks/index.ts b/mobile-expo/src/hooks/index.ts new file mode 100644 index 0000000..edfd870 --- /dev/null +++ b/mobile-expo/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './tasks'; +export * from './theme'; diff --git a/mobile-expo/src/hooks/tasks/index.ts b/mobile-expo/src/hooks/tasks/index.ts new file mode 100644 index 0000000..e06c859 --- /dev/null +++ b/mobile-expo/src/hooks/tasks/index.ts @@ -0,0 +1,101 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + CreateTaskPayload, + TGetTasksFilters, + TIdTask, + TTask, + UpdateTaskPayload, +} from '@/types'; +import { tasksApi } from '@/services'; +import { CONSTANTS } from '@/constants'; + +export const tasksKeysConfig = { + all: ['tasks'], + lists: () => [...tasksKeysConfig.all, 'list'], + list: (filters?: TGetTasksFilters) => [...tasksKeysConfig.lists(), filters], + details: () => [...tasksKeysConfig.all, 'detail'], + detail: (id: TIdTask) => [...tasksKeysConfig.details(), id], +}; + +export const useTasks = (filters?: TGetTasksFilters) => { + return useQuery({ + queryKey: tasksKeysConfig.list(filters), + queryFn: () => tasksApi.getTasks(filters), + staleTime: CONSTANTS.DEFAULT_STALE_TIME, + }); +}; + +export const useTask = (id: TIdTask) => { + return useQuery({ + queryKey: tasksKeysConfig.detail(id), + queryFn: () => tasksApi.getTaskById(id), + enabled: !!id, // only with id + staleTime: CONSTANTS.DEFAULT_STALE_TIME, + }); +}; + +export const useCreateTask = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: tasksApi.createTask, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: tasksKeysConfig.lists() }); + }, + onError: (error) => { + console.error('Failed to create task:', error); + }, + }); +}; + +export const useUpdateTask = () => { + const queryClient = useQueryClient(); + + return useMutation< + TTask, + Error, + { + id: TIdTask; + payload: UpdateTaskPayload; + } + >({ + mutationFn: ({ id, payload }) => tasksApi.updateTask(id, payload), + onSuccess: (data, variables) => { + queryClient.setQueryData(tasksKeysConfig.detail(variables.id), data); + queryClient.invalidateQueries({ queryKey: tasksKeysConfig.lists() }); + }, + onError: (error) => { + console.error('Failed to update task:', error); + }, + }); +}; + +export const useToggleTaskBlocked = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: tasksApi.toggleBlockedTask, + onSuccess: (data, taskId) => { + queryClient.setQueryData(tasksKeysConfig.detail(taskId), data); + queryClient.invalidateQueries({ queryKey: tasksKeysConfig.lists() }); + }, + onError: (error) => { + console.error('Failed to toggle task blocked status:', error); + }, + }); +}; + +export const useDeleteTask = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: tasksApi.deleteTask, + onSuccess: (_, taskId) => { + queryClient.removeQueries({ queryKey: tasksKeysConfig.detail(taskId) }); + queryClient.invalidateQueries({ queryKey: tasksKeysConfig.lists() }); + }, + onError: (error) => { + console.error('Failed to delete task:', error); + }, + }); +}; diff --git a/mobile-expo/src/hooks/theme/index.ts b/mobile-expo/src/hooks/theme/index.ts new file mode 100644 index 0000000..2f39729 --- /dev/null +++ b/mobile-expo/src/hooks/theme/index.ts @@ -0,0 +1,12 @@ +import { ThemeContext } from '@/providers'; +import { useContext } from 'react'; + +export const useTheme = () => { + const themeContext = useContext(ThemeContext); + + if (!themeContext) { + throw new Error('useTheme must be used within ThemeProvider'); + } + + return themeContext; +}; diff --git a/mobile-expo/src/navigation/AppNavigator.tsx b/mobile-expo/src/navigation/AppNavigator.tsx new file mode 100644 index 0000000..25ad840 --- /dev/null +++ b/mobile-expo/src/navigation/AppNavigator.tsx @@ -0,0 +1,10 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { BottomTabsNavigator } from './BottomTabsNavigator'; + +export const AppNavigator = () => { + return ( + + + + ); +}; diff --git a/mobile-expo/src/navigation/BottomTabsNavigator.tsx b/mobile-expo/src/navigation/BottomTabsNavigator.tsx new file mode 100644 index 0000000..d4d4239 --- /dev/null +++ b/mobile-expo/src/navigation/BottomTabsNavigator.tsx @@ -0,0 +1,45 @@ +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { TasksStackNavigator } from './TasksStackNavigator'; +import { SettingsStackNavigator } from './SettingsStackNavigator'; +import { useTheme } from '@/hooks'; + +export type BottomTabsParamList = { + TasksStack: undefined; + SettingsStack: undefined; +}; + +const Tab = createBottomTabNavigator(); + +export const BottomTabsNavigator = () => { + const { theme } = useTheme(); + + return ( + + + + + ); +}; diff --git a/mobile-expo/src/navigation/SettingsStackNavigator.tsx b/mobile-expo/src/navigation/SettingsStackNavigator.tsx new file mode 100644 index 0000000..48c6ad1 --- /dev/null +++ b/mobile-expo/src/navigation/SettingsStackNavigator.tsx @@ -0,0 +1,27 @@ +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { SettingsListScreen } from '@/screens'; +import { CustomHeader } from '@/components'; + +export type SettingsStackParamList = { + SettingList: undefined; +}; + +const Stack = createNativeStackNavigator(); + +export const SettingsStackNavigator = () => { + return ( + + ({ + header: () => ( + + ), + })} + /> + + ); +}; diff --git a/mobile-expo/src/navigation/TasksStackNavigator.tsx b/mobile-expo/src/navigation/TasksStackNavigator.tsx new file mode 100644 index 0000000..bab8cf2 --- /dev/null +++ b/mobile-expo/src/navigation/TasksStackNavigator.tsx @@ -0,0 +1,66 @@ +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { CreateTaskScreen, EditTaskScreen, TasksListScreen } from '@/screens'; +import { CustomHeader } from '@/components'; + +export type TasksStackParamList = { + TasksList: undefined; + CreateTask: undefined; + EditTask: { taskId: string }; +}; + +const Stack = createNativeStackNavigator(); + +export const TasksStackNavigator = () => { + return ( + + ({ + header: () => ( + navigation.navigate("CreateTask"), + }} + /> + ), + })} + /> + ({ + header: () => ( + navigation.goBack(), + }} + /> + ), + })} + /> + ({ + header: () => ( + navigation.goBack(), + }} + /> + ), + })} + /> + + ); +}; diff --git a/mobile-expo/src/navigation/index.ts b/mobile-expo/src/navigation/index.ts new file mode 100644 index 0000000..ed65d98 --- /dev/null +++ b/mobile-expo/src/navigation/index.ts @@ -0,0 +1,4 @@ +export { AppNavigator } from './AppNavigator'; +export { BottomTabsNavigator } from './BottomTabsNavigator'; +export { TasksStackNavigator } from './TasksStackNavigator'; +export { SettingsStackNavigator } from './SettingsStackNavigator'; diff --git a/mobile-expo/src/providers/AppProvider.tsx b/mobile-expo/src/providers/AppProvider.tsx new file mode 100644 index 0000000..5a43d12 --- /dev/null +++ b/mobile-expo/src/providers/AppProvider.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { TanstackProvider } from './TanstackProvider'; +import { ThemeProvider } from './ThemeProvider'; + +interface AppProviderProps { + children: ReactNode; +} + +export const AppProvider = ({ children }: AppProviderProps) => { + return ( + + + {children} + + + ); +}; diff --git a/mobile-expo/src/providers/TanstackProvider.tsx b/mobile-expo/src/providers/TanstackProvider.tsx new file mode 100644 index 0000000..646f809 --- /dev/null +++ b/mobile-expo/src/providers/TanstackProvider.tsx @@ -0,0 +1,27 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; +import { CONSTANTS } from '@/constants'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: CONSTANTS.DEFAULT_STALE_TIME, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + }, + mutations: { + retry: 0, + }, + }, +}); + +interface TanstackProviderProps { + children: ReactNode; +} + +export const TanstackProvider = ({ children }: TanstackProviderProps) => { + return ( + {children} + ); +}; diff --git a/mobile-expo/src/providers/ThemeProvider.tsx b/mobile-expo/src/providers/ThemeProvider.tsx new file mode 100644 index 0000000..ec69f92 --- /dev/null +++ b/mobile-expo/src/providers/ThemeProvider.tsx @@ -0,0 +1,66 @@ +import { darkTheme, lightTheme } from '@/styles'; +import { Theme, TThemeMode } from '@/types'; +import { createContext, ReactNode, useState } from 'react'; +import { useColorScheme } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; + +type TThemeContext = { + theme: Theme; + themeMode: TThemeMode; + setThemeMode: (mode: TThemeMode) => void; + toggleTheme: () => void; + isDarkTheme: boolean; +}; + +export const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: ReactNode; + initialMode?: TThemeMode; +} + +export const ThemeProvider = ({ + children, + initialMode = 'auto', +}: ThemeProviderProps) => { + const systemColorSchema = useColorScheme(); + const [themeMode, setThemeMode] = useState(initialMode); + + const getCurrentTheme = (): Theme => { + if (themeMode === 'auto') { + return systemColorSchema === 'dark' ? darkTheme : lightTheme; + } + + return themeMode === 'dark' ? darkTheme : lightTheme; + }; + + const theme = getCurrentTheme(); + + const isDarkTheme = theme === darkTheme; + const statusBarStyle = isDarkTheme ? 'light' : 'dark'; + + const toggleTheme = () => { + setThemeMode((prev) => { + if (prev === 'auto') { + return systemColorSchema === 'dark' ? 'light' : 'dark'; + } + + return prev === 'dark' ? 'light' : 'dark'; + }); + }; + + return ( + + + {children} + + ); +}; diff --git a/mobile-expo/src/providers/index.ts b/mobile-expo/src/providers/index.ts new file mode 100644 index 0000000..586a3df --- /dev/null +++ b/mobile-expo/src/providers/index.ts @@ -0,0 +1,3 @@ +export * from './TanstackProvider'; +export * from './AppProvider'; +export * from './ThemeProvider'; diff --git a/mobile-expo/src/screens/index.ts b/mobile-expo/src/screens/index.ts new file mode 100644 index 0000000..ef41430 --- /dev/null +++ b/mobile-expo/src/screens/index.ts @@ -0,0 +1,2 @@ +export * from './tasks'; +export * from './settings'; diff --git a/mobile-expo/src/screens/settings/SettingsListScreen.tsx b/mobile-expo/src/screens/settings/SettingsListScreen.tsx new file mode 100644 index 0000000..0e8520c --- /dev/null +++ b/mobile-expo/src/screens/settings/SettingsListScreen.tsx @@ -0,0 +1,13 @@ +import { ScreenWrapper, UISwitcher, UIText } from '@/components'; +import { useTheme } from '@/hooks'; + +export const SettingsListScreen = () => { + const { isDarkTheme, toggleTheme } = useTheme(); + + return ( + + + + + ); +}; diff --git a/mobile-expo/src/screens/settings/index.ts b/mobile-expo/src/screens/settings/index.ts new file mode 100644 index 0000000..13d24ab --- /dev/null +++ b/mobile-expo/src/screens/settings/index.ts @@ -0,0 +1 @@ +export { SettingsListScreen } from './SettingsListScreen'; diff --git a/mobile-expo/src/screens/tasks/CreateTaskScreen.tsx b/mobile-expo/src/screens/tasks/CreateTaskScreen.tsx new file mode 100644 index 0000000..e9bb79b --- /dev/null +++ b/mobile-expo/src/screens/tasks/CreateTaskScreen.tsx @@ -0,0 +1,9 @@ +import { ScreenWrapper, UIText } from "@/components"; + +export const CreateTaskScreen = () => { + return ( + + + + ); +}; \ No newline at end of file diff --git a/mobile-expo/src/screens/tasks/EditTaskScreen.tsx b/mobile-expo/src/screens/tasks/EditTaskScreen.tsx new file mode 100644 index 0000000..adb224e --- /dev/null +++ b/mobile-expo/src/screens/tasks/EditTaskScreen.tsx @@ -0,0 +1,9 @@ +import { ScreenWrapper, UIText } from '@/components'; + +export const EditTaskScreen = () => { + return ( + + + + ); +}; diff --git a/mobile-expo/src/screens/tasks/TasksListScreen.tsx b/mobile-expo/src/screens/tasks/TasksListScreen.tsx new file mode 100644 index 0000000..08efa35 --- /dev/null +++ b/mobile-expo/src/screens/tasks/TasksListScreen.tsx @@ -0,0 +1,27 @@ +import { FlatList, Text, View } from 'react-native'; +import { ScreenWrapper, TaskCard, UIText } from '@/components'; +import { useTasks } from '@/hooks'; + +export const TasksListScreen = () => { + const { data: tasks, isLoading, error } = useTasks(); + + if (isLoading) { + return ( + + + + ); + } + + + return ( + + + task.id} + renderItem={({ item }) => } + /> + + ); +}; diff --git a/mobile-expo/src/screens/tasks/index.ts b/mobile-expo/src/screens/tasks/index.ts new file mode 100644 index 0000000..5d7c13a --- /dev/null +++ b/mobile-expo/src/screens/tasks/index.ts @@ -0,0 +1,3 @@ +export { CreateTaskScreen } from './CreateTaskScreen'; +export { EditTaskScreen } from './EditTaskScreen'; +export { TasksListScreen } from './TasksListScreen'; diff --git a/mobile-expo/src/services/api/apiClient.ts b/mobile-expo/src/services/api/apiClient.ts new file mode 100644 index 0000000..ad683f7 --- /dev/null +++ b/mobile-expo/src/services/api/apiClient.ts @@ -0,0 +1,46 @@ +import axios from "axios"; +import { Platform } from "react-native"; +import { CONSTANTS } from "@/constants"; + +const getBaseUrl = () => { + const envUrl = process.env.NEST_BASE_URL || CONSTANTS.DEFAULT_URL; + + if (Platform.OS === "android" && envUrl.includes("localhost")) { + return envUrl.replace("localhost", "10.0.2.2"); + } + + return envUrl; +}; + +export const BASE_URL = getBaseUrl(); + +export const apiClient = axios.create({ + baseURL: BASE_URL, + timeout: 10000, + headers: { + "Content-Type": "application/json", + }, +}); + +apiClient.interceptors.request.use( + (config) => { + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + console.error('API Error:', error.response.data); + } else if (error.request) { + console.error('Network Error:', error.request); + } else { + console.error('Error:', error.message); + } + return Promise.reject(error); + } +); diff --git a/mobile-expo/src/services/api/index.ts b/mobile-expo/src/services/api/index.ts new file mode 100644 index 0000000..9cad454 --- /dev/null +++ b/mobile-expo/src/services/api/index.ts @@ -0,0 +1,2 @@ +export * from './apiClient'; +export * from './tasksApi'; diff --git a/mobile-expo/src/services/api/tasksApi.ts b/mobile-expo/src/services/api/tasksApi.ts new file mode 100644 index 0000000..f2b674b --- /dev/null +++ b/mobile-expo/src/services/api/tasksApi.ts @@ -0,0 +1,88 @@ +import { + CreateTaskPayload, + TGetTasksFilters, + TIdTask, + TTask, + UpdateTaskPayload, +} from "@/types"; +import { apiClient } from "./apiClient"; + +const TASKS_ROUTE = "/tasks"; + +const buildQueryParams = ( + filters?: TGetTasksFilters +): Record => { + if (!filters) return {}; + + const params: Record = {}; + + if (filters.status) { + params.status = filters.status; + } + + if (filters.priority) { + params.priority = filters.priority; + } + + if (filters.tags && filters.tags.length > 0) { + params.tags = filters.tags.join(","); + } + + if ( + filters.isBlocked !== undefined && + typeof filters.isBlocked === "boolean" + ) { + params.isBlocked = filters.isBlocked; + } + + if (filters.search && filters.search.trim().length > 0) { + params.search = filters.search.trim(); + } + + return params; +}; + +export const tasksApi = { + getTasks: async (filters?: TGetTasksFilters): Promise => { + const params = buildQueryParams(filters); + const response = await apiClient.get(`${TASKS_ROUTE}`, { params }); + + return response.data; + }, + + getTaskById: async (id: TIdTask): Promise => { + const response = await apiClient.get(`${TASKS_ROUTE}/${id}`); + + return response.data; + }, + + createTask: async (payload: CreateTaskPayload): Promise => { + const response = await apiClient.post(`${TASKS_ROUTE}`, payload); + + return response.data; + }, + + updateTask: async ( + id: TIdTask, + payload: UpdateTaskPayload + ): Promise => { + const response = await apiClient.patch( + `${TASKS_ROUTE}/${id}`, + payload + ); + + return response.data; + }, + + toggleBlockedTask: async (id: TIdTask): Promise => { + const response = await apiClient.patch( + `${TASKS_ROUTE}/${id}/toggle-blocked` + ); + + return response.data; + }, + + deleteTask: async (id: TIdTask): Promise => { + await apiClient.delete(`${TASKS_ROUTE}/${id}`); + }, +}; diff --git a/mobile-expo/src/services/index.ts b/mobile-expo/src/services/index.ts new file mode 100644 index 0000000..b1c13e7 --- /dev/null +++ b/mobile-expo/src/services/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/mobile-expo/src/styles/index.ts b/mobile-expo/src/styles/index.ts new file mode 100644 index 0000000..35001b5 --- /dev/null +++ b/mobile-expo/src/styles/index.ts @@ -0,0 +1,3 @@ +export * from './theme'; +export * from './spacing'; +export * from './typography'; diff --git a/mobile-expo/src/styles/spacing.ts b/mobile-expo/src/styles/spacing.ts new file mode 100644 index 0000000..d58efbf --- /dev/null +++ b/mobile-expo/src/styles/spacing.ts @@ -0,0 +1,10 @@ +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 20, + xl: 32, + xxl: 48, +} as const; + +export type TSpacing = typeof spacing; \ No newline at end of file diff --git a/mobile-expo/src/styles/theme/dark.ts b/mobile-expo/src/styles/theme/dark.ts new file mode 100644 index 0000000..1c819a4 --- /dev/null +++ b/mobile-expo/src/styles/theme/dark.ts @@ -0,0 +1,30 @@ +import { Theme } from "@/types"; + +export const darkTheme: Theme = { + colors: { + base: { + primary: '#BB86FC', + primaryDark: '#9845fe', + primaryLight: '#e1ccfc', + }, + text: { + primary: '#FFFFFF', + secondary: 'rgba(255, 255, 255, 0.7)', + }, + priority: { + low: '#66BB6A', + medium: '#FFA726', + high: '#EF5350', + }, + status: { + todo: '#757575', + inProgress: '#42A5F5', + done: '#66BB6A', + }, + background: { + background: '#121212', + overlay: 'rgba(0, 0, 0, 0.7)', + backdrop: 'rgba(0, 0, 0, 0.5)', + }, + }, +} as const; diff --git a/mobile-expo/src/styles/theme/index.ts b/mobile-expo/src/styles/theme/index.ts new file mode 100644 index 0000000..e50ef11 --- /dev/null +++ b/mobile-expo/src/styles/theme/index.ts @@ -0,0 +1,3 @@ +export * from './dark'; +export * from './light'; + diff --git a/mobile-expo/src/styles/theme/light.ts b/mobile-expo/src/styles/theme/light.ts new file mode 100644 index 0000000..ff0ad53 --- /dev/null +++ b/mobile-expo/src/styles/theme/light.ts @@ -0,0 +1,30 @@ +import { Theme } from "@/types"; + +export const lightTheme: Theme = { + colors: { + base: { + primary: '#6200EE', + primaryDark: '#3700B3', + primaryLight: '#ae74ff', + }, + text: { + primary: '#000000', + secondary: 'rgba(0, 0, 0, 0.6)', + }, + priority: { + low: '#4CAF50', + medium: '#FF9800', + high: '#F44336', + }, + status: { + todo: '#9E9E9E', + inProgress: '#2196F3', + done: '#4CAF50', + }, + background: { + background: '#FFFFFF', + overlay: 'rgba(0, 0, 0, 0.5)', + backdrop: 'rgba(0, 0, 0, 0.32)', + }, + }, +} as const; diff --git a/mobile-expo/src/styles/typography.ts b/mobile-expo/src/styles/typography.ts new file mode 100644 index 0000000..957c338 --- /dev/null +++ b/mobile-expo/src/styles/typography.ts @@ -0,0 +1,20 @@ +export const typography = { + fontSize: { + xs: 12, + sm: 14, + md: 16, + lg: 18, + xl: 20, + xxl: 24, + xxxl: 32, + }, + fontWeight: { + thin: '200' as const, + regular: '400' as const, + bold: '700' as const, + }, +} as const; + +export type TTypography = typeof typography; +export type TFontSize = keyof typeof typography.fontSize; +export type TFontWeight = keyof typeof typography.fontWeight; diff --git a/mobile-expo/src/types/index.ts b/mobile-expo/src/types/index.ts new file mode 100644 index 0000000..2c939b8 --- /dev/null +++ b/mobile-expo/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './navigation.types'; +export * from './task.types'; +export * from './theme.types'; diff --git a/mobile-expo/src/types/navigation.types.ts b/mobile-expo/src/types/navigation.types.ts new file mode 100644 index 0000000..cd6f8e5 --- /dev/null +++ b/mobile-expo/src/types/navigation.types.ts @@ -0,0 +1,7 @@ +import { BottomTabsParamList } from '@/navigation/BottomTabsNavigator'; + +declare global { + namespace ReactNavigation { + interface RootParamList extends BottomTabsParamList {} + } +} diff --git a/mobile-expo/src/types/task.types.ts b/mobile-expo/src/types/task.types.ts new file mode 100644 index 0000000..eb69835 --- /dev/null +++ b/mobile-expo/src/types/task.types.ts @@ -0,0 +1,63 @@ +export const enum EnumTaskTag { + WORK = 'work', + HOME = 'home', + PERSONAL = 'personal', + HEALTH = 'health', + SHOPPING = 'shopping', + FINANCE = 'finance', + EDUCATION = 'education', + FAMILY = 'family', + SOCIAL = 'social', + TRAVEL = 'travel', + FOOD = 'food', + CAR = 'car', + PET = 'pet', + HOBBY = 'hobby', + SPORT = 'sport', +} + +export const enum EnumTaskPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', +} + +export const enum EnumTaskStatus { + TODO = 'to-do', + IN_PROGRESS = 'in-progress', + DONE = 'done', +} + +export type TTask = { + id: string; + title: string; + description: string; + tags: EnumTaskTag[]; + priority: EnumTaskPriority; + status: EnumTaskStatus; + isBlocked: boolean; + createdAt: string; + updateAt: string; +}; + +export type TIdTask = TTask['id']; + +export type CreateTaskPayload = Pick< + TTask, + 'title' | 'description' | 'tags' | 'priority' +>; + +export type UpdateTaskPayload = Partial< + Pick< + TTask, + 'title' | 'description' | 'tags' | 'priority' | 'status' | 'isBlocked' + > +>; + +export type TGetTasksFilters = { + search?: string; + status?: EnumTaskStatus; + priority?: EnumTaskPriority; + tags?: EnumTaskTag[]; + isBlocked?: boolean; +}; diff --git a/mobile-expo/src/types/theme.types.ts b/mobile-expo/src/types/theme.types.ts new file mode 100644 index 0000000..a92694a --- /dev/null +++ b/mobile-expo/src/types/theme.types.ts @@ -0,0 +1,36 @@ +export type TThemeMode = 'auto' | 'light' | 'dark'; + +export type TThemeColors = { + base: { + primary: string; + primaryDark: string; + primaryLight: string; + }; + text: { + primary: string; + secondary: string; + }; + priority: { + low: string; + medium: string; + high: string; + }; + status: { + todo: string; + inProgress: string; + done: string; + }; + background: { + background: string; + overlay: string; + backdrop: string; + }; +}; + +export type Theme = { + colors: TThemeColors; +} + +export type TTextColor = keyof TThemeColors['text']; +export type TStatusColor = keyof TThemeColors['status']; +export type TPriorityColor = keyof TThemeColors['priority']; diff --git a/mobile-expo/src/utils/capitalizeString.ts b/mobile-expo/src/utils/capitalizeString.ts new file mode 100644 index 0000000..fc6b313 --- /dev/null +++ b/mobile-expo/src/utils/capitalizeString.ts @@ -0,0 +1,3 @@ +export const capitalizeString = (str: string): string => { + return String(str).charAt(0).toUpperCase() + String(str).slice(1); +}; diff --git a/mobile-expo/src/utils/index.ts b/mobile-expo/src/utils/index.ts new file mode 100644 index 0000000..bbb6d13 --- /dev/null +++ b/mobile-expo/src/utils/index.ts @@ -0,0 +1 @@ +export * from './capitalizeString'; diff --git a/mobile-expo/tsconfig.json b/mobile-expo/tsconfig.json new file mode 100644 index 0000000..a354d97 --- /dev/null +++ b/mobile-expo/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + } +}