├── .gitignore ├── .husky └── pre-commit ├── .huskyrc.json ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ ├── adaptive-icon.png │ ├── welcome-logo.png │ └── pawprint-wallpaper.png └── fonts │ └── SpaceMono-Regular.ttf ├── store ├── Auth.selector.ts ├── App.hooks.ts ├── Auth.action.ts ├── App.store.ts └── Auth.reducer.ts ├── babel.config.js ├── .prettierrc.js ├── components ├── StyledText.tsx ├── __tests__ │ ├── StyledText-test.js │ └── __snapshots__ │ │ ├── StyledText-test.js.snap │ │ └── StyledText-test.tsx.snap └── Themed.tsx ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── new-feature.md │ └── feature_request.md ├── constants ├── Layout.ts └── Colors.ts ├── hooks ├── useColorScheme.ts └── useCachedResources.ts ├── services ├── utils.ts ├── authService.ts ├── profileService.ts └── userService.ts ├── screens ├── TabTwoScreen.tsx ├── NotFoundScreen.tsx ├── LoginPage.tsx ├── EditProfile.tsx └── Profile.tsx ├── README.md ├── App.tsx ├── navigation ├── LinkingConfiguration.ts └── index.tsx ├── .eslintrc.js ├── app.json ├── types.tsx ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | yarn.lock 4 | .expo 5 | .env -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npm run test -- --watchAll=false" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/images/splash.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/welcome-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/images/welcome-logo.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/pawprint-wallpaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-app/main/assets/images/pawprint-wallpaper.png -------------------------------------------------------------------------------- /store/Auth.selector.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from "./App.store"; 2 | 3 | export const selectCookies = (state: RootState): string => state.auth.cookies; 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: ['inline-dotenv'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | jsxBracketSameLine: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | // Override any other rules you want 7 | }; 8 | -------------------------------------------------------------------------------- /components/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from './Themed'; 2 | 3 | export function MonoText(props: TextProps) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Expected Behavior 11 | 12 | ## Current Behavior 13 | 14 | ## Steps to reproduce 15 | 16 | 1. 17 | -------------------------------------------------------------------------------- /constants/Layout.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from 'react-native'; 2 | 3 | const width = Dimensions.get('window').width; 4 | const height = Dimensions.get('window').height; 5 | 6 | export default { 7 | window: { 8 | width, 9 | height, 10 | }, 11 | isSmallDevice: width < 375, 12 | }; 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New feature 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | ## Spec 13 | 14 | - [ ] 15 | - [ ] 16 | - [ ] 17 | - [ ] 18 | 19 | **Contact @ if you need help** 20 | -------------------------------------------------------------------------------- /components/__tests__/StyledText-test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { MonoText } from '../StyledText'; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /store/App.hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 2 | import { AppDispatch, RootState } from "./App.store"; 3 | 4 | // create types versions of useDispatch and useSelector 5 | export const useAppDispatch = () => useDispatch(); 6 | export const useAppSelector: TypedUseSelectorHook = useSelector; 7 | -------------------------------------------------------------------------------- /store/Auth.action.ts: -------------------------------------------------------------------------------- 1 | 2 | export namespace AuthAction { 3 | export enum Type { 4 | SET_COOKIES = 'SET_COOKIES' 5 | } 6 | 7 | interface SetCookies { 8 | type: typeof Type.SET_COOKIES, 9 | payload: string 10 | } 11 | 12 | export const setCookies = (payload: string): SetCookies => ({ 13 | type: Type.SET_COOKIES, 14 | payload 15 | }); 16 | 17 | } 18 | -------------------------------------------------------------------------------- /components/__tests__/__snapshots__/StyledText-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | 19 | Snapshot test! 20 | 21 | `; 22 | -------------------------------------------------------------------------------- /components/__tests__/__snapshots__/StyledText-test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | 19 | Snapshot test! 20 | 21 | `; 22 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | import { ColorSchemeName, useColorScheme as _useColorScheme } from 'react-native'; 2 | 3 | // The useColorScheme value is always either light or dark, but the built-in 4 | // type suggests that it can be null. This will not happen in practice, so this 5 | // makes it a bit easier to work with. 6 | export default function useColorScheme(): NonNullable { 7 | return _useColorScheme() as NonNullable; 8 | } 9 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | const tintColorLight = '#2f95dc'; 2 | const tintColorDark = '#fff'; 3 | 4 | export default { 5 | light: { 6 | text: '#000', 7 | background: '#fff', 8 | tint: tintColorLight, 9 | tabIconDefault: '#ccc', 10 | tabIconSelected: tintColorLight, 11 | }, 12 | dark: { 13 | text: '#fff', 14 | background: '#000', 15 | tint: tintColorDark, 16 | tabIconDefault: '#ccc', 17 | tabIconSelected: tintColorDark, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /store/App.store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { AuthReducer } from './Auth.reducer'; 3 | 4 | export const store = configureStore({ 5 | reducer: { 6 | auth: AuthReducer.authReducer 7 | }, 8 | }); 9 | 10 | // this also might need to be changed 11 | export interface ActionType { 12 | type: string, 13 | payload: T 14 | } 15 | 16 | // infer Rootstate and AppDispatch types from the store itself 17 | export type RootState = ReturnType; 18 | export type AppDispatch = typeof store.dispatch; 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /services/utils.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | 3 | export interface ResponseEnvelope { 4 | isOK: boolean; 5 | message?: string; 6 | response?: T; 7 | } 8 | 9 | export function assert(condition: any, msg?: string): asserts condition { 10 | if (!condition) { 11 | throw new Error(msg); 12 | } 13 | } 14 | 15 | export function unwrapOrThrowError( 16 | response: AxiosResponse>, 17 | ignoreResponse = false, 18 | ): T { 19 | if (response.data.isOK) { 20 | if (ignoreResponse) { 21 | return {} as T; 22 | } 23 | assert(response.data.response); 24 | return response.data.response; 25 | } 26 | throw new Error(`Error processing request: ${response.data.message}`); 27 | } 28 | -------------------------------------------------------------------------------- /screens/TabTwoScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | import { Text, View } from '../components/Themed'; 4 | 5 | export default function TabTwoScreen() { 6 | return ( 7 | 8 | Tab Two 9 | 10 | 11 | ); 12 | } 13 | 14 | const styles = StyleSheet.create({ 15 | container: { 16 | flex: 1, 17 | alignItems: 'center', 18 | justifyContent: 'center', 19 | }, 20 | title: { 21 | fontSize: 20, 22 | fontWeight: 'bold', 23 | }, 24 | separator: { 25 | marginVertical: 30, 26 | height: 1, 27 | width: '80%', 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Husky Habits App 2 | 3 | ## Setup 4 | 5 | Download [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable), a JavaScript package manager. 6 | 7 | Install dependencies: `yarn install` 8 | 9 | ### React Native 10 | 11 | React Native is a JavaScript library based on React for building mobile user interfaces. 12 | 13 | [Expo](https://github.com/expo/expo-cli) is a platform for creating/deploying React Native apps. 14 | 15 | Install Expo CLI: `npm install -g expo-cli` 16 | 17 | 18 | ## Development 19 | 20 | With Expo, you can launch a mobile phone emulator for Husky Habits either on your computer or phone. 21 | 22 | ```bash 23 | yarn start # you can open iOS, Android, or web from here, or run them directly with the commands below: 24 | - yarn android 25 | - yarn ios 26 | - yarn web 27 | ``` -------------------------------------------------------------------------------- /store/Auth.reducer.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from "redux"; 2 | import { ActionType } from "./App.store"; 3 | import { AuthAction } from "./Auth.action"; 4 | 5 | export namespace AuthReducer { 6 | export interface State { 7 | cookies: string 8 | } 9 | 10 | const initialState: State = { 11 | cookies: '' 12 | } 13 | 14 | // todo: i think this needs to be a union or something 15 | export type Action = ActionType; 16 | 17 | export const authReducer = (state = initialState, action: AnyAction): State => { 18 | switch (action.type) { 19 | case AuthAction.Type.SET_COOKIES: 20 | return { ...state, cookies: String(action.payload) }; 21 | default: 22 | return state; 23 | } 24 | } 25 | 26 | } 27 | 28 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar'; 2 | import { SafeAreaProvider } from 'react-native-safe-area-context'; 3 | import { Provider } from 'react-redux'; 4 | 5 | import useCachedResources from './hooks/useCachedResources'; 6 | import useColorScheme from './hooks/useColorScheme'; 7 | import Navigation from './navigation'; 8 | import { store } from './store/App.store'; 9 | 10 | export default function App() { 11 | const isLoadingComplete = useCachedResources(); 12 | const colorScheme = useColorScheme(); 13 | 14 | if (!isLoadingComplete) { 15 | return null; 16 | } else { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /navigation/LinkingConfiguration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about deep linking with React Navigation 3 | * https://reactnavigation.org/docs/deep-linking 4 | * https://reactnavigation.org/docs/configuring-links 5 | */ 6 | 7 | import { LinkingOptions } from '@react-navigation/native'; 8 | import * as Linking from 'expo-linking'; 9 | 10 | import { RootStackParamList } from '../types'; 11 | 12 | const linking: LinkingOptions = { 13 | prefixes: [Linking.createURL('/')], 14 | config: { 15 | screens: { 16 | Root: { 17 | screens: { 18 | Profile: { 19 | screens: { 20 | Profile: 'profile', 21 | EditProfile: 'modal', 22 | }, 23 | }, 24 | TabTwo: { 25 | screens: { 26 | TabTwoScreen: 'two', 27 | }, 28 | }, 29 | }, 30 | }, 31 | Login: 'login', 32 | NotFound: '*', 33 | }, 34 | }, 35 | }; 36 | 37 | export default linking; 38 | -------------------------------------------------------------------------------- /screens/NotFoundScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, TouchableOpacity } from 'react-native'; 2 | 3 | import { Text, View } from '../components/Themed'; 4 | import { RootStackScreenProps } from '../types'; 5 | 6 | export default function NotFoundScreen({ navigation }: RootStackScreenProps<'NotFound'>) { 7 | return ( 8 | 9 | This screen doesn't exist. 10 | navigation.replace('Root')} style={styles.link}> 11 | Go to home screen! 12 | 13 | 14 | ); 15 | } 16 | 17 | const styles = StyleSheet.create({ 18 | container: { 19 | flex: 1, 20 | alignItems: 'center', 21 | justifyContent: 'center', 22 | padding: 20, 23 | }, 24 | title: { 25 | fontSize: 20, 26 | fontWeight: 'bold', 27 | }, 28 | link: { 29 | marginTop: 15, 30 | paddingVertical: 15, 31 | }, 32 | linkText: { 33 | fontSize: 14, 34 | color: '#2e78b7', 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ['plugin:react/recommended', 'airbnb'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | ecmaVersion: 'latest', 13 | sourceType: 'module', 14 | }, 15 | plugins: ['react', 'react-native', '@typescript-eslint'], 16 | rules: { 17 | 'no-use-before-define': 'off', 18 | 'react/no-unstable-nested-components': 'off', 19 | 'react/jsx-uses-react': 'off', 20 | 'react/react-in-jsx-scope': 'off', 21 | 'react/jsx-props-no-spreading': 'off', 22 | 'react/jsx-filename-extension': 'off', 23 | 'import/extensions': 'off', 24 | 'import/no-unresolved': 'off', 25 | 'no-unstable-nested-components': 'off', 26 | '@typescript-eslint/no-unused-vars': [ 27 | 'error', 28 | { 29 | args: 'none', 30 | }, 31 | ], 32 | 'react/prop-types': [ 33 | 'error', 34 | { ignore: ['navigation', 'navigation.navigate'] }, 35 | ], 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "huskyhabits", 4 | "slug": "huskyhabits", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "exp", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "plugins": [ 16 | [ 17 | "expo-image-picker", 18 | { 19 | "photosPermission": "The app accesses your photos to let you share them with your friends." 20 | } 21 | ] 22 | ], 23 | "updates": { 24 | "fallbackToCacheTimeout": 0 25 | }, 26 | "assetBundlePatterns": [ 27 | "**/*" 28 | ], 29 | "ios": { 30 | "supportsTablet": true 31 | }, 32 | "android": { 33 | "adaptiveIcon": { 34 | "foregroundImage": "./assets/images/adaptive-icon.png", 35 | "backgroundColor": "#ffffff" 36 | } 37 | }, 38 | "web": { 39 | "favicon": "./assets/images/favicon.png" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /hooks/useCachedResources.ts: -------------------------------------------------------------------------------- 1 | import { FontAwesome } from '@expo/vector-icons'; 2 | import * as Font from 'expo-font'; 3 | import * as SplashScreen from 'expo-splash-screen'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | export default function useCachedResources() { 7 | const [isLoadingComplete, setLoadingComplete] = useState(false); 8 | 9 | // Load any resources or data that we need prior to rendering the app 10 | useEffect(() => { 11 | async function loadResourcesAndDataAsync() { 12 | try { 13 | SplashScreen.preventAutoHideAsync(); 14 | 15 | // Load fonts 16 | await Font.loadAsync({ 17 | ...FontAwesome.font, 18 | 'space-mono': require('../assets/fonts/SpaceMono-Regular.ttf'), 19 | }); 20 | } catch (e) { 21 | // We might want to provide this error information to an error reporting service 22 | console.warn(e); 23 | } finally { 24 | setLoadingComplete(true); 25 | SplashScreen.hideAsync(); 26 | } 27 | } 28 | 29 | loadResourcesAndDataAsync(); 30 | }, []); 31 | 32 | return isLoadingComplete; 33 | } 34 | -------------------------------------------------------------------------------- /services/authService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosResponse } from 'axios'; 2 | import { assert } from './utils'; 3 | import * as Linking from 'expo-linking'; 4 | import * as WebBrowser from 'expo-web-browser'; 5 | import { Buffer, constants } from 'buffer'; 6 | import * as SecureStore from 'expo-secure-store'; 7 | import { useAppDispatch } from '../store/App.hooks'; 8 | import { AuthAction } from '../store/Auth.action'; 9 | 10 | export default class AuthServiceClient { 11 | private _axios: AxiosInstance; 12 | private _baseURL: string; 13 | 14 | constructor(serviceUrl?: string) { 15 | const baseURL = 16 | 'http://' + process.env.BACKEND_URL || ''; 17 | this._baseURL = baseURL; 18 | assert(baseURL); 19 | this._axios = axios.create({ baseURL }); 20 | } 21 | 22 | 23 | async loginWithGoogle(redirectUri: string) { 24 | const url = `${this._baseURL}/auth/google${`?auth_redirect_uri=${redirectUri}`}`; 25 | 26 | 27 | try { 28 | const resp = await WebBrowser.openAuthSessionAsync(url, await Linking.getInitialURL() || ''); 29 | 30 | if (resp.type == 'success') { 31 | const {queryParams} = Linking.parse(resp.url) 32 | 33 | const cookies = queryParams['cookies'] 34 | await SecureStore.setItemAsync('auth-cookies', Buffer.from(cookies, 'base64').toString('ascii')) 35 | } 36 | 37 | 38 | } catch(e) { 39 | return new Error(`WARNING: could not open link: ${url}`); 40 | } 41 | 42 | // Below won't work in local testing (need a web browser to open google oauth page) 43 | // leaving here while still in early development 44 | 45 | // await this._axios.get( 46 | // `/google${redirectUri ? `?auth_redirect_uri=${redirectUri}` : ''}`); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /components/Themed.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about Light and Dark modes: 3 | * https://docs.expo.io/guides/color-schemes/ 4 | */ 5 | 6 | import { Text as DefaultText, View as DefaultView, TextInput as DefaultTextInput } from 'react-native'; 7 | 8 | import Colors from '../constants/Colors'; 9 | import useColorScheme from '../hooks/useColorScheme'; 10 | 11 | export function useThemeColor( 12 | props: { light?: string; dark?: string }, 13 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 14 | ) { 15 | const theme = useColorScheme(); 16 | const colorFromProps = props[theme]; 17 | 18 | if (colorFromProps) { 19 | return colorFromProps; 20 | } else { 21 | return Colors[theme][colorName]; 22 | } 23 | } 24 | 25 | type ThemeProps = { 26 | lightColor?: string; 27 | darkColor?: string; 28 | }; 29 | 30 | export type TextProps = ThemeProps & DefaultText['props']; 31 | export type ViewProps = ThemeProps & DefaultView['props']; 32 | export type TextInputProps = ThemeProps & DefaultTextInput['props']; 33 | 34 | export function Text(props: TextProps) { 35 | const { style, lightColor, darkColor, ...otherProps } = props; 36 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 37 | 38 | return ; 39 | } 40 | 41 | export function TextInput(props: TextInputProps) { 42 | const { style, lightColor, darkColor, ...otherProps } = props; 43 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 44 | 45 | return ; 46 | } 47 | 48 | export function View(props: ViewProps) { 49 | const { style, lightColor, darkColor, ...otherProps } = props; 50 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 51 | 52 | return ; 53 | } 54 | -------------------------------------------------------------------------------- /types.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about using TypeScript with React Navigation: 3 | * https://reactnavigation.org/docs/typescript/ 4 | */ 5 | 6 | import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; 7 | import { 8 | CompositeScreenProps, 9 | NavigatorScreenParams, 10 | } from '@react-navigation/native'; 11 | import { NativeStackScreenProps } from '@react-navigation/native-stack'; 12 | 13 | declare global { 14 | namespace ReactNavigation { 15 | interface RootParamList extends RootStackParamList {} 16 | } 17 | } 18 | 19 | export type AuthStackParamList = { 20 | Root: NavigatorScreenParams | undefined; 21 | EditProfile: undefined; 22 | NotFound: undefined; 23 | } 24 | 25 | export type AuthStackScreenProps = { 26 | } 27 | 28 | export type RootStackParamList = { 29 | Root: NavigatorScreenParams | undefined; 30 | Profile: undefined; 31 | EditProfile: undefined; 32 | Login: undefined; 33 | NotFound: undefined; 34 | }; 35 | 36 | export type RootStackScreenProps = 37 | NativeStackScreenProps; 38 | 39 | export type AuthTabParamList = { 40 | Profile: undefined; 41 | TabTwo: undefined; 42 | }; 43 | 44 | export type RootModalParamList = { 45 | Profile: undefined; 46 | EditProfile: undefined; 47 | } 48 | 49 | export type RootParamList = { 50 | Login: undefined; 51 | } 52 | 53 | export type RootScreenProps = 54 | NativeStackScreenProps; 55 | 56 | export type RootStackModalProps = 57 | NativeStackScreenProps; 58 | 59 | export type RootTabScreenProps = 60 | CompositeScreenProps< 61 | BottomTabScreenProps, 62 | NativeStackScreenProps 63 | >; -------------------------------------------------------------------------------- /services/profileService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios'; 2 | import { assert } from './utils'; 3 | import { ResponseEnvelope, unwrapOrThrowError } from './utils'; 4 | 5 | export interface CreateProfileRequest { 6 | username: string; 7 | bio?: string; 8 | } 9 | 10 | export interface CreateProfileResponse { 11 | profileId: string; 12 | } 13 | 14 | export interface GetProfileRequest { 15 | profileId: string; 16 | } 17 | 18 | export interface GetProfileResponse { 19 | userId: string; 20 | username: string; 21 | bio: string; 22 | photo: { data: Buffer; contentType: string }; 23 | } 24 | 25 | export interface GetProfileFriendsRequest { 26 | profileId: string; 27 | } 28 | 29 | export interface GetProfileFriendsResponse { 30 | friends: [ 31 | { 32 | username: string; 33 | bio: string; 34 | photo: { data: Buffer; contentType: string }; 35 | }, 36 | ]; 37 | } 38 | 39 | export default class ProfileServicesClient { 40 | private _axios: AxiosInstance; 41 | 42 | constructor(serviceUrl?: string) { 43 | const baseURL = 44 | serviceUrl || 45 | `http://${process.env.BACKEND_URL}/v1/profiles`; 46 | assert(baseURL); 47 | this._axios = axios.create({ baseURL }); 48 | } 49 | 50 | async createProfile( 51 | requestData: CreateProfileRequest, 52 | ): Promise { 53 | const responseWrapper = await this._axios.post< 54 | ResponseEnvelope 55 | >('/', requestData); 56 | return unwrapOrThrowError(responseWrapper); 57 | } 58 | 59 | async getProfileById( 60 | requestData: GetProfileRequest, 61 | ): Promise { 62 | const responseWrapper = await this._axios.get< 63 | ResponseEnvelope 64 | >(`/${requestData.profileId}`); 65 | return unwrapOrThrowError(responseWrapper); 66 | } 67 | 68 | async getFriendsForProfile( 69 | requestData: GetProfileFriendsRequest, 70 | ): Promise { 71 | const responseWrapper = await this._axios.get< 72 | ResponseEnvelope 73 | >(`/${requestData.profileId}/friends`); 74 | return unwrapOrThrowError(responseWrapper); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /services/userService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosResponse } from 'axios'; 2 | import { assert } from './utils'; 3 | import { ResponseEnvelope, unwrapOrThrowError } from './utils'; 4 | 5 | export interface GetUserResponse { 6 | userId: string; 7 | email: string; 8 | firstName: string; 9 | lastName: string; 10 | accounts: [{ acc_type: string; uid: string }]; 11 | } 12 | 13 | export interface GetUserChallengesResponse { 14 | // TODO 15 | } 16 | 17 | export interface GetUserFriendRequestsResponse { 18 | // TODO 19 | } 20 | 21 | export interface GetUserAvatarRequest { 22 | userId: string; 23 | size: 'sm' | 'md' | 'lg'; 24 | } 25 | 26 | export interface GetUserAvatarResponse { 27 | // TODO 28 | } 29 | 30 | export default class UserServiceClient { 31 | private _axios: AxiosInstance; 32 | 33 | constructor(serviceUrl?: string) { 34 | const baseURL = 35 | serviceUrl || 36 | `http://${process.env.BACKEND_URL}/v1/users`; 37 | assert(baseURL); 38 | this._axios = axios.create({ baseURL }); 39 | } 40 | 41 | async getUserById(requestData: { userId: string }): Promise { 42 | const responseWrapper = await this._axios.get< 43 | ResponseEnvelope 44 | >(`/${requestData.userId}`); 45 | return unwrapOrThrowError(responseWrapper); 46 | } 47 | 48 | async getUserChallenges(requestData: { 49 | userId: string; 50 | }): Promise { 51 | const responseWrapper = await this._axios.get< 52 | ResponseEnvelope 53 | >(`/${requestData.userId}/challenges`); 54 | return unwrapOrThrowError(responseWrapper); 55 | } 56 | 57 | async getUserFriendRequests(requestData: { 58 | userId: string; 59 | }): Promise { 60 | const responseWrapper = await this._axios.get< 61 | ResponseEnvelope 62 | >(`/${requestData.userId}/friend_requests`); 63 | return unwrapOrThrowError(responseWrapper); 64 | } 65 | 66 | async getUserAvatar( 67 | requestData: GetUserAvatarRequest, 68 | ): Promise { 69 | const responseWrapper = await this._axios.get< 70 | ResponseEnvelope 71 | >(`/users/${requestData.userId}/avatar?size=${requestData.size}`); 72 | return unwrapOrThrowError(responseWrapper); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huskyhabits-app", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "eject": "expo eject", 11 | "test": "jest --watchAll" 12 | }, 13 | "jest": { 14 | "preset": "jest-expo" 15 | }, 16 | "dependencies": { 17 | "@expo/vector-icons": "^12.0.0", 18 | "@react-navigation/bottom-tabs": "^6.0.5", 19 | "@react-navigation/native": "^6.0.2", 20 | "@react-navigation/native-stack": "^6.1.0", 21 | "@reduxjs/toolkit": "^1.8.0", 22 | "@types/react-redux": "^7.1.23", 23 | "axios": "^0.26.1", 24 | "babel-plugin-inline-dotenv": "^1.7.0", 25 | "buffer": "^6.0.3", 26 | "expo": "~44.0.0", 27 | "expo-asset": "~8.4.4", 28 | "expo-auth-session": "~3.5.0", 29 | "expo-font": "~10.0.4", 30 | "expo-image-picker": "~12.0.1", 31 | "expo-linking": "~3.0.0", 32 | "expo-secure-store": "~11.1.0", 33 | "expo-splash-screen": "~0.14.0", 34 | "expo-status-bar": "~1.2.0", 35 | "expo-web-browser": "~10.1.0", 36 | "react": "17.0.1", 37 | "react-dom": "17.0.1", 38 | "react-native": "0.64.3", 39 | "react-native-elements": "^3.4.2", 40 | "react-native-image-picker": "^4.7.3", 41 | "react-native-keyboard-aware-scroll-view": "^0.9.5", 42 | "react-native-safe-area-context": "3.3.2", 43 | "react-native-screens": "~3.10.1", 44 | "react-native-web": "0.17.1", 45 | "react-redux": "^7.2.6", 46 | "redux": "^4.1.2" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.12.9", 50 | "@types/react": "^17.0.38", 51 | "@types/react-native": "^0.66.15", 52 | "@typescript-eslint/eslint-plugin": "^5.10.1", 53 | "@typescript-eslint/parser": "^5.10.1", 54 | "eslint": "^8.7.0", 55 | "eslint-config-airbnb": "^19.0.4", 56 | "eslint-plugin-import": "^2.25.4", 57 | "eslint-plugin-jsx-a11y": "^6.5.1", 58 | "eslint-plugin-react": "^7.28.0", 59 | "eslint-plugin-react-hooks": "^4.3.0", 60 | "eslint-plugin-react-native": "^4.0.0", 61 | "husky": "^7.0.4", 62 | "jest": "^26.6.3", 63 | "jest-expo": "~44.0.1", 64 | "lint-staged": "^12.3.1", 65 | "prettier": "^2.5.1", 66 | "react-native-typescript-transformer": "^1.2.13", 67 | "react-test-renderer": "17.0.1", 68 | "ts-jest": "^27.1.3", 69 | "typescript": "^4.5.5" 70 | }, 71 | "lint-staged": { 72 | "*.{js,jsx,ts,tsx}": [ 73 | "prettier --write", 74 | "eslint --fix" 75 | ] 76 | }, 77 | "private": true 78 | } 79 | -------------------------------------------------------------------------------- /screens/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { ImageBackground, Button, ScrollView } from "react-native" 2 | import { Image, StyleSheet } from 'react-native'; 3 | import {SocialIcon} from 'react-native-elements'; 4 | import { Text, View, TextInput } from '../components/Themed'; 5 | import React, { useEffect } from 'react'; 6 | import AuthServiceClient from '../services/authService'; 7 | import * as Linking from 'expo-linking'; 8 | import { RootStackScreenProps } from "../types"; 9 | import { useAppDispatch } from "../store/App.hooks"; 10 | import * as SecureStore from 'expo-secure-store'; 11 | import { AuthAction } from "../store/Auth.action"; 12 | 13 | export default function Login({ navigation }: RootStackScreenProps<'Login'>) { 14 | const authClient: AuthServiceClient = new AuthServiceClient(); 15 | const dispatch = useAppDispatch(); 16 | 17 | const handleAuth = async () => { 18 | const initialUrl = await Linking.getInitialURL() as string; 19 | const oAuthLogin = await authClient.loginWithGoogle(initialUrl); 20 | // returns error 21 | if (oAuthLogin) { 22 | console.log("OAuth failed"); 23 | } 24 | 25 | const cookies = await SecureStore.getItemAsync('auth-cookies'); 26 | 27 | dispatch(AuthAction.setCookies(cookies || '')); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | 40 | 41 | {/* */} 42 | 43 | ); 44 | } 45 | 46 | const styles = StyleSheet.create({ 47 | container: { 48 | flex: 1, 49 | padding: 1 50 | }, 51 | pageContainer: { 52 | alignItems: 'center', 53 | justifyContent: 'center', 54 | flex: 1, 55 | }, 56 | input: { 57 | height: 40, 58 | width: 200, 59 | margin: 5, 60 | borderWidth: 1, 61 | padding: 10, 62 | }, 63 | multilineInput: { 64 | height: 100, 65 | width: 200, 66 | margin: 5, 67 | borderWidth: 1, 68 | padding: 10, 69 | }, 70 | inputContainer: { 71 | flexDirection: "row", 72 | alignItems: "center", 73 | }, 74 | profileImage: { 75 | width: 100, 76 | height: 100, 77 | borderRadius: 50, 78 | borderColor: 'black', 79 | borderWidth: 1, 80 | }, 81 | image: { 82 | flex: 1, 83 | justifyContent: "center" 84 | }, 85 | photoContainer: { 86 | flexDirection: "column", 87 | alignItems: 'center', 88 | justifyContent: 'center', 89 | margin: 5, 90 | }, 91 | profileContainer: { 92 | paddingVertical: 20, 93 | alignItems: 'center', 94 | }, 95 | changeImageLabel: { 96 | marginTop: 5, 97 | }, 98 | textLabel: { 99 | textAlign: "right", 100 | width: 100, 101 | fontSize: 15, 102 | marginRight: 10, 103 | padding: 0, 104 | }, 105 | title: { 106 | fontSize: 30, 107 | fontWeight: 'bold', 108 | margin: 20, 109 | textAlign: 'center', 110 | }, 111 | separator: { 112 | marginVertical: 15, 113 | height: 1, 114 | width: '80%', 115 | }, 116 | }); 117 | -------------------------------------------------------------------------------- /screens/EditProfile.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Image, StyleSheet } from 'react-native'; 3 | import { Text, View, TextInput } from '../components/Themed'; 4 | import * as ImagePicker from 'expo-image-picker'; 5 | import { Buffer } from 'buffer'; 6 | import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; 7 | 8 | 9 | export default function EditProfile() { 10 | const [username, setUsername] = useState(""); 11 | const [firstName, setFirstName] = useState(""); 12 | const [lastName, setLastName] = useState(""); 13 | const [bio, setBio] = useState(""); 14 | const [photoBuffer, setPhotoBuffer] = useState(null); 15 | const [photoURI, setPhotoURI] = useState(""); 16 | 17 | const onChangeImage = async () => { 18 | let result = await ImagePicker.launchImageLibraryAsync({ 19 | mediaTypes: ImagePicker.MediaTypeOptions.All, 20 | allowsEditing: true, 21 | base64: true, 22 | aspect: [1, 1], 23 | quality: 1, 24 | }); 25 | 26 | if (!result.cancelled) { 27 | if (result.base64) { 28 | const buffer: Buffer = Buffer.from(result.base64, "base64"); 29 | setPhotoURI("data:image/jpeg;base64,"+result.base64); 30 | setPhotoBuffer(buffer); 31 | } 32 | } 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | 45 | 50 | Change profile photo 51 | 52 | 53 | 58 | 59 | Username 60 | 67 | 68 | 69 | First Name 70 | 77 | 78 | 79 | Last Name 80 | 88 | 89 | 90 | Bio 91 | 102 | 103 | 104 | 105 | ); 106 | } 107 | 108 | const styles = StyleSheet.create({ 109 | container: { 110 | flex: 1, 111 | }, 112 | input: { 113 | height: 40, 114 | width: 200, 115 | margin: 5, 116 | borderWidth: 1, 117 | padding: 10, 118 | }, 119 | multilineInput: { 120 | height: 100, 121 | width: 200, 122 | margin: 5, 123 | borderWidth: 1, 124 | padding: 10, 125 | }, 126 | inputContainer: { 127 | flexDirection: "row", 128 | alignItems: "center", 129 | }, 130 | profileImage: { 131 | width: 100, 132 | height: 100, 133 | borderRadius: 50, 134 | borderColor: 'black', 135 | borderWidth: 1, 136 | }, 137 | photoContainer: { 138 | flexDirection: "column", 139 | alignItems: 'center', 140 | justifyContent: 'center', 141 | margin: 5, 142 | }, 143 | profileContainer: { 144 | paddingVertical: 20, 145 | alignItems: 'center', 146 | }, 147 | changeImageLabel: { 148 | marginTop: 5, 149 | }, 150 | textLabel: { 151 | textAlign: "right", 152 | width: 100, 153 | fontSize: 15, 154 | marginRight: 10, 155 | padding: 0, 156 | }, 157 | title: { 158 | fontSize: 20, 159 | fontWeight: 'bold', 160 | }, 161 | separator: { 162 | marginVertical: 15, 163 | height: 1, 164 | width: '80%', 165 | }, 166 | }); 167 | -------------------------------------------------------------------------------- /screens/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Image, ScrollView } from 'react-native'; 2 | import { FontAwesome } from '@expo/vector-icons'; 3 | import { Text, View } from '../components/Themed'; 4 | import { Button } from 'react-native'; 5 | import * as SecureStore from 'expo-secure-store'; 6 | import { useAppDispatch } from '../store/App.hooks'; 7 | import { AuthAction } from '../store/Auth.action'; 8 | 9 | export default function ProfileScreen() { 10 | const dispatch = useAppDispatch(); 11 | 12 | 13 | const logout = () => { 14 | SecureStore.deleteItemAsync('auth-cookies').then(() => { 15 | alert('Logged out'); 16 | }) 17 | 18 | dispatch(AuthAction.setCookies('')); 19 | } 20 | 21 | return ( 22 | 23 | 24 | 30 | Ross Newman 31 | @ross3102 32 | 33 | 34 | Boston, MA, USA 35 | 36 | 37 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do 38 | eiusmod tempor incididunt ut labore et dolore magna aliqua. 39 | 40 | 41 | 47 | 48 | 53 | 54 | 55 | Groups 56 | 57 | 63 | 64 | Group 1 65 | 10 members 66 | 67 | 68 | 69 | 75 | 76 | Group 2 77 | 150 members 78 | 79 | 80 | 81 | 87 | 88 | Group 3 89 | 2 members 90 | 91 | 92 | 93 | 99 | 100 | Group 4 101 | 50 members 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | } 109 | 110 | const styles = StyleSheet.create({ 111 | container: { 112 | flex: 1, 113 | }, 114 | profileContainer: { 115 | alignItems: 'center', 116 | justifyContent: 'center', 117 | backgroundColor: '#666', 118 | padding: 10, 119 | margin: 10, 120 | }, 121 | title: { 122 | fontSize: 25, 123 | fontWeight: 'bold', 124 | }, 125 | handle: { 126 | fontSize: 20, 127 | color: '#999', 128 | }, 129 | locationView: { 130 | alignItems: 'center', 131 | justifyContent: 'center', 132 | backgroundColor: '#666', 133 | flexDirection: 'row', 134 | marginBottom: 10, 135 | marginTop: 5, 136 | }, 137 | bio: { 138 | textAlign: 'center', 139 | }, 140 | profileImage: { 141 | width: 100, 142 | height: 100, 143 | borderRadius: 50, 144 | borderColor: 'black', 145 | borderWidth: 1, 146 | }, 147 | groupsContainer: { 148 | alignItems: 'center', 149 | justifyContent: 'center', 150 | backgroundColor: '#666', 151 | width: '100%', 152 | }, 153 | separator: { 154 | marginVertical: 30, 155 | height: 1, 156 | width: '80%', 157 | }, 158 | group: { 159 | alignItems: 'center', 160 | flexDirection: 'row', 161 | backgroundColor: '#aaa', 162 | borderColor: 'black', 163 | borderWidth: 1, 164 | width: '100%', 165 | marginTop: 10, 166 | }, 167 | groupImage: { 168 | width: 100, 169 | height: 100, 170 | }, 171 | groupInfo: { 172 | backgroundColor: '#aaa', 173 | margin: 10, 174 | }, 175 | groupName: { 176 | fontSize: 20, 177 | fontWeight: 'bold', 178 | }, 179 | }); 180 | -------------------------------------------------------------------------------- /navigation/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * If you are not familiar with React Navigation, refer to the "Fundamentals" guide: 3 | * https://reactnavigation.org/docs/getting-started 4 | * 5 | */ 6 | import { FontAwesome } from '@expo/vector-icons'; 7 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 8 | import { 9 | NavigationContainer, 10 | DefaultTheme, 11 | DarkTheme, 12 | } from '@react-navigation/native'; 13 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 14 | import * as React from 'react'; 15 | import { Button, ColorSchemeName, Pressable } from 'react-native'; 16 | import useColorScheme from '../hooks/useColorScheme'; 17 | import LoginPage from '../screens/LoginPage'; 18 | import EditProfile from '../screens/EditProfile'; 19 | import NotFoundScreen from '../screens/NotFoundScreen'; 20 | import ProfileScreen from '../screens/Profile'; 21 | import TabTwoScreen from '../screens/TabTwoScreen'; 22 | import { 23 | RootStackModalProps, 24 | AuthStackParamList, 25 | AuthStackScreenProps, 26 | AuthTabParamList, 27 | RootTabScreenProps, 28 | RootScreenProps, 29 | RootParamList, 30 | } from '../types'; 31 | import LinkingConfiguration from './LinkingConfiguration'; 32 | import * as SecureStore from 'expo-secure-store'; 33 | import { useSelector } from 'react-redux'; 34 | import { selectCookies } from '../store/Auth.selector'; 35 | 36 | export default function Navigation({ 37 | colorScheme, 38 | }: { 39 | colorScheme: ColorSchemeName; 40 | }) { 41 | 42 | // const isUser: boolean = false 43 | const [authenticated, setAuthenticated] = React.useState(false); 44 | const cookies = useSelector(selectCookies); 45 | 46 | React.useEffect(() => { 47 | setAuthenticated(cookies !== ''); 48 | 49 | }, [ cookies ]); 50 | 51 | return ( 52 | 56 | {authenticated ? : } 57 | 58 | ); 59 | } 60 | 61 | /** 62 | * AuthNavigator holds all screens for authenticated users. 63 | */ 64 | const AuthStack = createNativeStackNavigator(); 65 | 66 | function AuthNavigator() { 67 | return ( 68 | 69 | 73 | 78 | 79 | ) => ({ 83 | title: 'Edit Profile', 84 | headerRight: () => ( 85 |