├── .editorconfig ├── .eslintrc.js ├── .expo-shared └── assets.json ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .gitlab-ci.yml ├── .yarn └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── App.js ├── README.md ├── app.json ├── assets ├── React-icon.svg ├── adaptive-icon.png ├── bg.svg ├── deck.svg ├── edit.png ├── eth_wallet.png ├── favicon.png ├── fonts │ ├── deck │ │ ├── config.json │ │ └── deck.ttf │ └── fontello-48ca1594.zip ├── go.png ├── icon.png ├── kanban.png ├── mail.png ├── more.png ├── plus.png ├── screenshots │ ├── 400x800bb_1.png │ ├── 400x800bb_2.png │ ├── 400x800bb_3.png │ ├── 400x800bb_4.png │ ├── paywall.jpg │ └── paywall_full.jpg └── splash.png ├── babel.config.js ├── components ├── AppMenu.js ├── AssigneeList.js ├── AttachmentPanel.js ├── Board.js ├── Card.js ├── CommentPanel.js ├── Icon.js ├── LabelList.js └── Spinner.js ├── eas.json ├── i18n ├── i18n.js └── languages.js ├── index.js ├── package-lock.json ├── package.json ├── store ├── boardSlice.js ├── colorSchemeSlice.js ├── serverSlice.js ├── store.js ├── themeSlice.js └── tokenSlice.js ├── styles └── base.js ├── utils.js └── views ├── AllBoards.js ├── BoardDetails.js ├── CardDetails.js ├── Home.js ├── Login.js └── Settings.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "settings": { 3 | "react": { 4 | "version": "detect", 5 | }, 6 | }, 7 | "env": { 8 | "browser": true, 9 | "commonjs": true, 10 | "es2021": true 11 | }, 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:react/recommended" 15 | ], 16 | "parserOptions": { 17 | "sourceType": "module", 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": 2021 22 | }, 23 | "plugins": [ 24 | "react" 25 | ], 26 | "rules": { 27 | "react/prop-types": 0 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '24 7 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | #- name: Autobuild 56 | # uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | npm-debug.* 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | web-build/ 11 | 12 | # macOS 13 | .DS_Store 14 | 15 | # app specifics 16 | builds 17 | 18 | # yarn 19 | .pnp.* 20 | .yarn/* 21 | !.yarn/patches 22 | !.yarn/plugins 23 | !.yarn/releases 24 | !.yarn/sdks 25 | !.yarn/versions 26 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node 2 | 3 | stages: 4 | - lint 5 | 6 | eslint: 7 | stage: lint 8 | script: 9 | - npm i eslint eslint-plugin-react 10 | - node_modules/eslint/bin/eslint.js --version 11 | - node_modules/eslint/bin/eslint.js . 12 | 13 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 2 | nodeLinker: node-modules 3 | -------------------------------------------------------------------------------- /App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AsyncStorage from '@react-native-async-storage/async-storage' 3 | import { KeyboardAvoidingView, Appearance } from 'react-native' 4 | import { NavigationContainer, DefaultTheme, DarkTheme } from '@react-navigation/native' 5 | import { createStackNavigator } from '@react-navigation/stack' 6 | import Toast from 'react-native-toast-message' 7 | import * as Font from 'expo-font' 8 | import * as SplashScreen from 'expo-splash-screen' 9 | import Login from './views/Login' 10 | import Home from './views/Home' 11 | import AllBoards from './views/AllBoards' 12 | import BoardDetails from './views/BoardDetails' 13 | import CardDetails from './views/CardDetails' 14 | import Settings from './views/Settings' 15 | import { connect } from 'react-redux' 16 | import { bindActionCreators } from 'redux' 17 | import { setServer } from './store/serverSlice' 18 | import { setTheme } from './store/themeSlice' 19 | import { setToken } from './store/tokenSlice' 20 | import { setColorScheme } from './store/colorSchemeSlice' 21 | import * as Linking from 'expo-linking' // For creating an URL handler to retrieve the device token 22 | import {encode as btoa} from 'base-64' // btoa isn't supported by android (and maybe also iOS) 23 | import * as Device from 'expo-device' 24 | import * as ScreenOrientation from 'expo-screen-orientation' 25 | import { adapty } from 'react-native-adapty' // in-app purchases 26 | import mobileAds from 'react-native-google-mobile-ads' 27 | import { AdEventType, AdsConsent, AppOpenAd } from 'react-native-google-mobile-ads' 28 | import { isUserSubscribed } from './utils' 29 | 30 | // Creates Stack navigator 31 | const Stack = createStackNavigator() 32 | 33 | // Prevents native splash screen from autohiding before App component declaration 34 | SplashScreen.preventAutoHideAsync().catch(console.warn) 35 | 36 | // Application 37 | class App extends React.Component { 38 | 39 | async loadFonts() { 40 | console.log('Loading fonts') 41 | await Font.loadAsync({ 42 | deck: require('./assets/fonts/deck/deck.ttf'), 43 | }) 44 | this.setState({ fontsLoaded: true }) 45 | } 46 | 47 | constructor(props) { 48 | 49 | console.log('initialising app') 50 | 51 | super(props) 52 | 53 | this.state = { 54 | fontsLoaded: false, 55 | colorScheme: 'light', 56 | navigation: {}, 57 | } 58 | 59 | // Force portrait mode on iPhones 60 | if (Device.modelId && Device.modelId.startsWith('iPhone')) { 61 | ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP) 62 | } 63 | 64 | // Register handler to catch Nextcloud's redirect after successfull login 65 | Linking.addEventListener('url', (url) => {this.handleRedirect(url)}) 66 | 67 | // Activates adapty and show ad if needed afterward 68 | console.log('Activating adapty') 69 | adapty.activate('public_live_dQQGIW4b.wZU2qtAbVtrojrx9ttUu').then( async () =>{ 70 | console.log('adapty activated, waiting 1 second before checking user\'subscribtion') 71 | setTimeout(() => this.showAdIfNeeded(), 1000) 72 | } ) 73 | 74 | } 75 | 76 | // Try to show ad if user hasn't subscribed to a paying version of the app 77 | // For the moment, we just let the user use the app freely if he doesn't give consent for ads 78 | async showAdIfNeeded() { 79 | console.log('Adapty activated, checking if user is subscribed to a paying version of the app') 80 | if (! await isUserSubscribed()) { 81 | // User hasn't subscribed to a paying version of the app, we'll try to show him/her ads. 82 | // Checks if we need to re-ask consent (eg: due to conditions change at provider-side) 83 | console.log('User has not subscribed to a paying version of the app, trying to display ads') 84 | console.log('Checking if we need to ask user consent to display ads') 85 | const consentInfo = await AdsConsent.requestInfoUpdate() 86 | if (consentInfo.status !== 'OBTAINED') { 87 | // Asks consent 88 | console.log('Asking user consent') 89 | await AdsConsent.loadAndShowConsentFormIfRequired(); 90 | } else { 91 | console.log('Nope, we don\'t') 92 | } 93 | 94 | // Shows ad if user gaves enough consent 95 | console.log('checking user consents') 96 | const userChoice = await AdsConsent.getUserChoices(); 97 | if (userChoice.storeAndAccessInformationOnDevice) { 98 | // Initializes ads 99 | console.log('Ok we got user consent to display ads') 100 | mobileAds().initialize().then(() => { 101 | let requestOptions = { requestNonPersonalizedAdsOnly: true } 102 | if (!userChoice.selectPersonalisedContent) { 103 | console.log('Not for personalised ads though') 104 | requestOptions = {} 105 | } 106 | const appOpenAd = AppOpenAd.createForAdRequest("ca-app-pub-8838289832709828/1694360664", requestOptions) 107 | appOpenAd.addAdEventListener(AdEventType.LOADED, () => { 108 | console.log('Showing ad') 109 | appOpenAd.show() 110 | }); 111 | console.log('Loading ad') 112 | appOpenAd.load() 113 | }) 114 | } else { 115 | // For the moment, we just let the user use the app freely if he doesn't give consent for ads 116 | console.log('User did not gave enough consent to use admob (missing "storeAndAccessInformationOnDevice" right)') 117 | } 118 | } else { 119 | console.log('User is subscribed to a paying version of the app') 120 | } 121 | } 122 | 123 | async componentDidMount() { 124 | 125 | this.loadFonts() 126 | 127 | // Sets theme 128 | AsyncStorage.getItem('colorScheme').then(savedColorScheme => { 129 | 130 | let colorScheme 131 | if (savedColorScheme !== null && savedColorScheme !== 'os') { 132 | colorScheme = savedColorScheme 133 | console.log('colorScheme retrieved', colorScheme) 134 | this.props.setColorScheme(colorScheme) 135 | } else { 136 | // Using os colorscheme 137 | colorScheme = Appearance.getColorScheme() 138 | console.log('using OS colorsSheme') 139 | this.props.setColorScheme('os') 140 | } 141 | 142 | this.setState({ colorScheme: colorScheme}) 143 | this.props.setTheme(colorScheme) 144 | }) 145 | 146 | // Registers theme change subscription 147 | this._schemeSubscription = Appearance.addChangeListener(({ colorScheme }) => { 148 | this.setState({ colorScheme }) 149 | this.props.setTheme(colorScheme) 150 | this.props.setColorScheme(colorScheme) 151 | }); 152 | 153 | // Retrieves last viewed board and stack if available 154 | AsyncStorage.getItem('navigation').then(navigation => { 155 | navigation = navigation != null ? JSON.parse(navigation) : { boardId: null, stackId: null } 156 | console.log('Retrieved last navigated board+stack') 157 | this.setState({navigation}) 158 | }) 159 | 160 | // Retrieve token from storage if available 161 | AsyncStorage.getItem('NCtoken').then(token => { 162 | if (token !== null) { 163 | console.log('token retrieved from asyncStorage') 164 | this.props.setToken('Basic ' + token) 165 | AsyncStorage.getItem('NCserver').then(server => { 166 | if (server !== null) { 167 | console.log('server retrieved from asyncStorage') 168 | this.props.setServer(server) 169 | } 170 | }) 171 | } 172 | }) 173 | 174 | SplashScreen.hideAsync() 175 | 176 | } 177 | 178 | // Function to retrieve the device's token and save it after user logged in 179 | async handleRedirect({url}) { 180 | if (url.startsWith('nc://login/server')) { 181 | let user = decodeURIComponent(url.substring(url.lastIndexOf('user:')+5, url.lastIndexOf('&'))).replace(/\+/g, ' ') 182 | console.log('User is', user) 183 | const pwd = url.substring(url.lastIndexOf(':')+1) 184 | const token = btoa(user + ':' + pwd) 185 | console.log('Persisting token in asyncStorage', token) 186 | // TODO Use expo-secure-store to securely store the token 187 | AsyncStorage.setItem('NCtoken', token); 188 | console.log('Saving token in store') 189 | this.props.setToken('Basic ' + token) 190 | } 191 | } 192 | 193 | render() { 194 | if(this.state.fontsLoaded && Object.keys(this.state.navigation).length !== 0) { 195 | if (this.props.token.value === null || this.props.server.value === null) { 196 | // No token is stored yet, we need to get one 197 | return ( 198 | 199 | {return {detachPreviousScreen: !navigation.isFocused()}}}> 200 | 201 | 202 | 203 | 204 | ) 205 | } else { 206 | return ( 207 | 208 | 209 | {return {detachPreviousScreen: !navigation.isFocused()}}} 211 | initialRouteName='AllBoards' 212 | > 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | ) 222 | } 223 | } else { 224 | return null 225 | } 226 | } 227 | 228 | } 229 | 230 | // Connect to store 231 | const mapStateToProps = state => ({ 232 | colorScheme: state.colorScheme, 233 | server: state.server, 234 | theme: state.theme, 235 | token: state.token, 236 | }) 237 | const mapDispatchToProps = dispatch => ( 238 | bindActionCreators( { 239 | setColorScheme, 240 | setServer, 241 | setToken, 242 | setTheme, 243 | }, dispatch) 244 | ) 245 | export default connect( 246 | mapStateToProps, 247 | mapDispatchToProps 248 | )(App) 249 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-Native Nextcloud Deck application 2 | A react-native client for [Nextcloud Deck App](https://github.com/nextcloud/deck/). 3 | 4 | Android users may want to look at `https://github.com/stefan-niedermann/nextcloud-deck` which is a much more mature project. 5 | 6 | # Contribute 7 | 8 | ## Test 9 | 10 | Contact me at `cyr.deck [at] bollu.be` to become part of the internal tester group. 11 | 12 | Test the app and report as much bugs as you can at (preferably) https://framagit.org/StCyr/deck-react-native. 13 | 14 | ## Develop 15 | 16 | Development using expo: 17 | 18 | * Clone the repository: `git clone https://framagit.org/StCyr/deck-react-native` or `https://github.com/StCyr/deck-react-native` 19 | * cd into your clone directory 20 | * Setup your build environment: 21 | * Register your device: `eas device:create` 22 | * Build the app: `eas build --profile development --platform ios` 23 | * Start expo: `npx expo start` 24 | * Start hacking around 25 | * Submit MR's and PR's 26 | 27 | ## Publishing 28 | 29 | * Build the app for production: `eas build --platform ios` 30 | * Submit the app to the appstore: `eas submit --platform ios` 31 | 32 | ## Financial support 33 | 34 | You may help me developing this app by donating ETH or EUR: 35 | 36 | ![ETH wallet address](/assets/eth_wallet.png) 37 | 38 | https://www.paypal.com/donate?hosted_button_id=86NDKXPNVA58Q 39 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Nextcloud Deck", 4 | "description": "The power of Nextcloud Deck on your smartphone", 5 | "slug": "deck-react-native", 6 | "version": "0.18.0", 7 | "icon": "./assets/icon.png", 8 | "scheme": "nc", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "plugins": [ 16 | [ 17 | "expo-document-picker", 18 | { 19 | "iCloudContainerEnvironment": "Production" 20 | } 21 | ], 22 | "expo-localization" 23 | ], 24 | "updates": { 25 | "fallbackToCacheTimeout": 0, 26 | "url": "https://u.expo.dev/4004f171-004b-4987-bdc0-8ab0fe8ceed9" 27 | }, 28 | "assetBundlePatterns": [ 29 | "**/*" 30 | ], 31 | "ios": { 32 | "supportsTablet": true, 33 | "bundleIdentifier": "be.bollu.deck-react-native", 34 | "buildNumber": "62", 35 | "usesIcloudStorage": true 36 | }, 37 | "android": { 38 | "adaptiveIcon": { 39 | "foregroundImage": "./assets/adaptive-icon.png", 40 | "backgroundColor": "#FFFFFF" 41 | }, 42 | "package": "be.bollu.deck_react_native", 43 | "versionCode": 62, 44 | "permissions": [] 45 | }, 46 | "web": { 47 | "favicon": "./assets/favicon.png" 48 | }, 49 | "runtimeVersion": { 50 | "policy": "sdkVersion" 51 | }, 52 | "extra": { 53 | "eas": { 54 | "projectId": "4004f171-004b-4987-bdc0-8ab0fe8ceed9" 55 | } 56 | } 57 | }, 58 | "react-native-google-mobile-ads": { 59 | "ios_app_id": "ca-app-pub-8838289832709828~2648311699", 60 | "delay_app_measurement_init": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /assets/React-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/bg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/deck.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/edit.png -------------------------------------------------------------------------------- /assets/eth_wallet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/eth_wallet.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/favicon.png -------------------------------------------------------------------------------- /assets/fonts/deck/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "icon-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "59ba4f90d2ca2a843f4f930b03270362", 11 | "css": "plus-circle", 12 | "code": 59392, 13 | "src": "iconic" 14 | }, 15 | { 16 | "uid": "bbfb51903f40597f0b70fd75bc7b5cac", 17 | "css": "trash", 18 | "code": 61944, 19 | "src": "fontawesome" 20 | }, 21 | { 22 | "uid": "fbb01025c73ffb726bf28b98619df36d", 23 | "css": "eye", 24 | "code": 59393, 25 | "src": "iconic" 26 | }, 27 | { 28 | "uid": "750058837a91edae64b03d60fc7e81a7", 29 | "css": "ellipsis-vert", 30 | "code": 61762, 31 | "src": "fontawesome" 32 | }, 33 | { 34 | "uid": "37910a1398001a5ccb541cb2a253a2c7", 35 | "css": "right-open", 36 | "code": 61446, 37 | "src": "mfglabs" 38 | }, 39 | { 40 | "uid": "98687378abd1faf8f6af97c254eb6cd6", 41 | "css": "cog-alt", 42 | "code": 59394, 43 | "src": "fontawesome" 44 | }, 45 | { 46 | "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6", 47 | "css": "pencil", 48 | "code": 59395, 49 | "src": "fontawesome" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /assets/fonts/deck/deck.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/fonts/deck/deck.ttf -------------------------------------------------------------------------------- /assets/fonts/fontello-48ca1594.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/fonts/fontello-48ca1594.zip -------------------------------------------------------------------------------- /assets/go.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/go.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/icon.png -------------------------------------------------------------------------------- /assets/kanban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/kanban.png -------------------------------------------------------------------------------- /assets/mail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/mail.png -------------------------------------------------------------------------------- /assets/more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/more.png -------------------------------------------------------------------------------- /assets/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/plus.png -------------------------------------------------------------------------------- /assets/screenshots/400x800bb_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/screenshots/400x800bb_1.png -------------------------------------------------------------------------------- /assets/screenshots/400x800bb_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/screenshots/400x800bb_2.png -------------------------------------------------------------------------------- /assets/screenshots/400x800bb_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/screenshots/400x800bb_3.png -------------------------------------------------------------------------------- /assets/screenshots/400x800bb_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/screenshots/400x800bb_4.png -------------------------------------------------------------------------------- /assets/screenshots/paywall.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/screenshots/paywall.jpg -------------------------------------------------------------------------------- /assets/screenshots/paywall_full.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/screenshots/paywall_full.jpg -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StCyr/deck-react-native/8f4cc8db0adced8e44f32f509fd1fe01d4b85a42/assets/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: ['react-native-reanimated/plugin'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /components/AppMenu.js: -------------------------------------------------------------------------------- 1 | //=============================================================================================================================================== 2 | // 3 | // AppMenu: The three-dots menu in the upper-right corner of every screens 4 | // 5 | // This file is part of "Nextcloud Deck". 6 | // 7 | // "Nextcloud Deck" is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 8 | // as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 9 | // 10 | // "Nextcloud Deck" is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warrant 11 | // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License along with "Nextcloud Deck". If not, see . 14 | // 15 | //=============================================================================================================================================== 16 | 17 | import React from 'react' 18 | import { useSelector } from 'react-redux' 19 | import { Pressable, View } from 'react-native' 20 | import Icon from './Icon.js' 21 | 22 | const AppMenu = ({navigation}) => { 23 | 24 | const theme = useSelector(state => state.theme) 25 | 26 | return ( 27 | 28 | {navigation.navigate('Settings')}}> 29 | 30 | 31 | 32 | ) 33 | 34 | } 35 | 36 | export default AppMenu 37 | -------------------------------------------------------------------------------- /components/AssigneeList.js: -------------------------------------------------------------------------------- 1 | //=============================================================================================================================================== 2 | // 3 | // AssigneeList: A component to show or modify a card's assignees 4 | // 5 | // This file is part of "Nextcloud Deck". 6 | // 7 | // "Nextcloud Deck" is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 8 | // as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 9 | // 10 | // "Nextcloud Deck" is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warrant 11 | // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License along with "Nextcloud Deck". If not, see . 14 | // 15 | //=============================================================================================================================================== 16 | 17 | import React, { useEffect, useState } from 'react' 18 | import { Image, View } from 'react-native' 19 | import { Avatar } from 'react-native-elements' 20 | import DropDownPicker from 'react-native-dropdown-picker' 21 | import { useSelector } from 'react-redux' 22 | 23 | // AssigneeList is displayed into a scrollview and I'm assuming there won't ever be a ton of users to display 24 | // See also https://hossein-zare.github.io/react-native-dropdown-picker-website/docs/advanced/list-modes#notes 25 | DropDownPicker.setListMode("SCROLLVIEW"); 26 | 27 | const AssigneeList = ({editable, boardUsers, cardAssignees, size='normal', udpateCardAsigneesHandler}) => { 28 | 29 | const [open, setOpen] = useState(false); 30 | const [value, setValue] = useState(cardAssignees.map(user => user.participant.uid)); 31 | 32 | const theme = useSelector(state => state.theme) 33 | const server = useSelector(state => state.server) 34 | 35 | // Updates parent when value changes 36 | useEffect(() => { 37 | if (typeof udpateCardAsigneesHandler !== 'undefined') { 38 | udpateCardAsigneesHandler(value) 39 | } 40 | }, [value]) 41 | 42 | // Returns an URI to get a user's avatar 43 | const getUserUri = (user) => { 44 | if (size === 'small') { 45 | return server.value + '/index.php/avatar/' + user.participant.uid + '/32?v=2' 46 | } else { 47 | return server.value + '/index.php/avatar/' + user.participant.uid + '/40?v=2' 48 | } 49 | } 50 | 51 | // Computes the list of selectable users for the DropDownPicker 52 | const items = boardUsers?.map(user => { 53 | return { 54 | icon: () => , 55 | label: user.displayname, 56 | value: user.uid 57 | } 58 | }) 59 | 60 | // Renders component 61 | if (editable) { 62 | return ( 63 | 70 | ) 71 | } else { 72 | return ( 73 | 74 | {cardAssignees.map(user => 75 | 82 | )} 83 | 84 | ) 85 | } 86 | 87 | } 88 | 89 | export default AssigneeList -------------------------------------------------------------------------------- /components/AttachmentPanel.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { useRoute } from '@react-navigation/native' 4 | import { addCard } from '../store/boardSlice' 5 | import { Alert, Pressable, View } from 'react-native' 6 | import { Text } from 'react-native-elements' 7 | import * as DocumentPicker from 'expo-document-picker' 8 | import * as FileSystem from 'expo-file-system' 9 | import FileViewer from 'react-native-file-viewer' 10 | import axios from 'axios' 11 | import {Collapse,CollapseHeader, CollapseBody} from 'accordion-collapse-react-native' 12 | import * as Localization from 'expo-localization' 13 | import Toast from 'react-native-toast-message' 14 | import Icon from './Icon.js' 15 | import {i18n} from '../i18n/i18n.js' 16 | import {decode as atob} from 'base-64'; 17 | import { fetchAttachments, getAttachmentURI } from '../utils' 18 | import * as ImagePicker from 'expo-image-picker'; 19 | 20 | // The attachments div that's displayed in the CardDetails view 21 | const AttachmentPanel = ({card, updateCard, showSpinner}) => { 22 | 23 | const theme = useSelector(state => state.theme) 24 | const server = useSelector(state => state.server) 25 | const token = useSelector(state => state.token) 26 | const user = atob(token.value.substring(6)).split(':')[0] 27 | const dispatch = useDispatch() 28 | 29 | const route = useRoute() 30 | 31 | // ComponentDidMount 32 | useEffect(() => { 33 | }, []) 34 | 35 | // Fetches card's attachments 36 | const fetchAttachmentsIfNeeded = async () => { 37 | if (card.attachments) { 38 | return card 39 | } 40 | showSpinner(true) 41 | const attachments = await fetchAttachments(route.params.boardId, route.params.stackId, route.params.cardId, server, token.value) 42 | const cardWithAttachments = { 43 | ...card, 44 | ...{'attachments': attachments} 45 | } 46 | updateCard(cardWithAttachments) 47 | showSpinner(false) 48 | return cardWithAttachments 49 | } 50 | 51 | // Adds an attachment to the card 52 | const addAttachment = async (attachmentType) => { 53 | try { 54 | // Selects document 55 | let resp 56 | if (attachmentType === 'photo') { 57 | resp = await ImagePicker.launchImageLibraryAsync({ 58 | mediaTypes: ImagePicker.MediaTypeOptions.All, 59 | allowsMultipleSelection: true, 60 | quality: 1, 61 | }); 62 | } else if (attachmentType === 'camera') { 63 | const result = await ImagePicker.requestCameraPermissionsAsync(); 64 | if (result.granted) { 65 | resp = await ImagePicker.launchCameraAsync({ 66 | mediaTypes: ImagePicker.MediaTypeOptions.All, 67 | quality: 1, 68 | }); 69 | } 70 | } else { 71 | resp = await DocumentPicker.getDocumentAsync({copyToCacheDirectory: false}) 72 | } 73 | 74 | const status = resp.type ? resp.type : !resp.canceled ? resp.assets.type : 'cancel' 75 | 76 | if (status !== 'cancel') { 77 | 78 | const uri = resp.uri ? resp.uri : resp.assets[0].uri 79 | // Uploads attachment 80 | showSpinner(true) 81 | console.log('Uploading attachment', uri) 82 | FileSystem.uploadAsync( 83 | server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}/attachments`, 84 | uri, 85 | { 86 | fieldName: 'file', 87 | httpMethod: 'POST', 88 | uploadType: FileSystem.FileSystemUploadType.MULTIPART, 89 | headers: { 90 | 'Content-Type': 'application/json', 91 | 'Authorization': token.value 92 | }, 93 | parameters: { 94 | type: 'file' 95 | } 96 | }, 97 | ) 98 | .then(async (resp) => { 99 | if (resp.status === 200) { 100 | console.log('Attachment uploaded') 101 | 102 | // Makes sure we have the existing card attachments, if any 103 | let tempCard = card 104 | if (tempCard.attachmentCount && tempCard.attachments === null) { 105 | tempCard = await fetchAttachmentsIfNeeded() 106 | } 107 | 108 | // Saves card in store and updates frontend 109 | let cardWithNewAttachment 110 | let attachment = JSON.parse(resp.body) 111 | if (tempCard.attachmentCount) { 112 | cardWithNewAttachment = { 113 | ...tempCard, 114 | ...{ 115 | 'attachmentCount': tempCard.attachmentCount + 1, 116 | 'attachments': [ 117 | ...tempCard.attachments, 118 | ...[{ 119 | author: attachment.createdBy, 120 | creationDate: new Date().toLocaleDateString(Localization.locale, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }), 121 | id: attachment.id, 122 | name: attachment.data 123 | }] 124 | ] 125 | } 126 | } 127 | } else { 128 | cardWithNewAttachment = { 129 | ...tempCard, 130 | ...{ 131 | 'attachmentCount': 1, 132 | 'attachments': [{ 133 | author: attachment.createdBy, 134 | creationDate: new Date().toLocaleDateString(Localization.locale, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }), 135 | id: attachment.id, 136 | name: attachment.data 137 | }] 138 | } 139 | } 140 | } 141 | dispatch(addCard({ 142 | boardId: route.params.boardId, 143 | stackId: route.params.stackId, 144 | card: cardWithNewAttachment 145 | })) 146 | updateCard(cardWithNewAttachment) 147 | console.log('Card updated in store') 148 | showSpinner(false) 149 | } else { 150 | Toast.show({ 151 | type: 'error', 152 | text1: i18n.t('error'), 153 | text2: JSON.parse(resp.body).message, 154 | }) 155 | console.log(JSON.parse(resp.body).message) 156 | showSpinner(false) 157 | } 158 | }) 159 | .catch((error) => { 160 | Toast.show({ 161 | type: 'error', 162 | text1: i18n.t('error'), 163 | text2: error.message, 164 | }) 165 | console.log('error', error) 166 | showSpinner(false) 167 | }) 168 | } 169 | } 170 | catch (error) { 171 | Toast.show({ 172 | type: 'error', 173 | text1: i18n.t('error'), 174 | text2: error.message, 175 | }) 176 | console.log(error) 177 | } 178 | } 179 | 180 | // Opens an attachment 181 | const openAttachment = async (attachment) => { 182 | const uri = await getAttachmentURI(attachment, route.params.boardId, route.params.stackId, route.params.cardId, server, token.value) 183 | if (uri !== null) { 184 | FileViewer.open(uri) 185 | } 186 | } 187 | 188 | // Deletes an attachment 189 | const deleteAttachement = async (attachment) => { 190 | console.log(`deleting attachment ${attachment.id}`) 191 | axios.delete(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}/attachments/${attachment.id}`, 192 | { 193 | timeout: 8000, 194 | headers: { 195 | 'Content-Type': 'application/json', 196 | 'Authorization': token.value, 197 | }, 198 | data: { 199 | 'type': 'file' 200 | } 201 | }).then((resp) => { 202 | if (resp.status !== 200) { 203 | Toast.show({ 204 | type: 'error', 205 | text1: i18n.t('error'), 206 | text2: resp, 207 | }) 208 | console.log('Error', resp) 209 | } else { 210 | // Saves card in store and updates frontend 211 | let newCard 212 | newCard = { 213 | ...card, 214 | ...{ 215 | 'attachmentCount': card.attachmentCount -1, 216 | 'attachments': card.attachments.filter(a => a.id !== attachment.id) 217 | } 218 | } 219 | dispatch(addCard({ 220 | boardId: route.params.boardId, 221 | stackId: route.params.stackId, 222 | card: newCard 223 | })) 224 | updateCard(newCard) 225 | console.log('attachment deleted') 226 | } 227 | }) 228 | } 229 | 230 | return ( 231 | 234 | 235 | 236 | 237 | {i18n.t('attachments') + ' (' + card.attachmentCount + ')'} 238 | 239 | { 240 | Alert.alert( 241 | i18n.t("attachmentSource"), 242 | i18n.t("attachmentSourcePrompt"), [ 243 | { 244 | text: i18n.t("photoGallery"), 245 | onPress: () => {addAttachment('photo')}, 246 | }, 247 | { 248 | text: i18n.t("camera"), 249 | onPress: () => {addAttachment('camera')}, 250 | }, 251 | { 252 | text: i18n.t("document"), 253 | onPress: () => {addAttachment('document')} 254 | }, 255 | { 256 | text: i18n.t("cancel"), 257 | style: 'cancel' 258 | } 259 | ] 260 | ) 261 | }}> 262 | 263 | 264 | 265 | 266 | 267 | {card.attachments ? card.attachments.map(attachment => ( 268 | 269 | openAttachment(attachment)}> 270 | 271 | 272 | {attachment.author} 273 | 274 | 275 | {attachment.creationDate} 276 | 277 | 278 | 279 | {attachment.name} 280 | 281 | 282 | 283 | deleteAttachement(attachment)} disabled={user!==attachment.author}> 284 | 285 | 286 | 287 | 288 | ) 289 | ) : null} 290 | 291 | 292 | ) 293 | 294 | } 295 | 296 | export default AttachmentPanel 297 | -------------------------------------------------------------------------------- /components/Board.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AsyncStorage from '@react-native-async-storage/async-storage'; 3 | import { ActionSheetIOS, Pressable, View, Text, TextInput } from 'react-native'; 4 | import { initialWindowMetrics } from 'react-native-safe-area-context'; 5 | import { connect } from 'react-redux'; 6 | import { bindActionCreators } from 'redux' 7 | import Toast from 'react-native-toast-message'; 8 | import { addBoard, deleteBoard, renameBoard } from '../store/boardSlice'; 9 | import Icon from './Icon'; 10 | import axios from 'axios'; 11 | import {i18n} from '../i18n/i18n.js'; 12 | 13 | // Component representing a user board 14 | class Board extends React.Component { 15 | 16 | constructor(props) { 17 | super(props) 18 | this.state = { 19 | newBoardName: this.props.board.title, // Stores the new board's name when editing a board's title 20 | renamingBoard: false // Set to true to make the board's title editable 21 | } 22 | this.insets = initialWindowMetrics?.insets ?? { 23 | left: 0, 24 | right: 0, 25 | bottom: 0, 26 | top: 0, 27 | } 28 | } 29 | 30 | render() { 31 | return ( 32 | { 34 | // Opens board's details page 35 | console.log(`navigating to board ${this.props.board.id}`) 36 | this.props.navigation.navigate('BoardDetails',{ 37 | boardId: this.props.board.id, 38 | stackId: null, 39 | }) 40 | AsyncStorage.setItem('navigation', JSON.stringify({ 41 | boardId: this.props.board.id, 42 | stackId: null, 43 | })); 44 | }} 45 | onLongPress={() => { 46 | // Context menu 47 | ActionSheetIOS.showActionSheetWithOptions( 48 | { 49 | options: [i18n.t("cancel"), i18n.t("rename"), i18n.t("archive"), i18n.t("delete")], 50 | destructiveButtonIndex: 3, 51 | cancelButtonIndex: 0, 52 | }, 53 | buttonIndex => { 54 | if (buttonIndex === 0) { 55 | // cancel action 56 | } else if (buttonIndex === 1) { 57 | // Makes title editable 58 | this.setState({ renamingBoard: true }) 59 | } else if (buttonIndex === 2) { 60 | this.archiveBoard() 61 | } else if (buttonIndex === 3) { 62 | this.deleteBoard() 63 | } 64 | } 65 | ) 66 | }} 67 | style={this.props.theme.card} > 68 | 69 | {!this.state.renamingBoard && 70 | // Read only title 71 | 72 | {this.props.board.title} 73 | 74 | } 75 | {this.state.renamingBoard && 76 | // Editable title 77 | { 82 | this.setState({ renamingBoard: false }) 83 | this.setState({ newBoardName: '' }) 84 | }} 85 | onChangeText={newBoardName => { 86 | this.setState({ newBoardName }) 87 | }} 88 | onSubmitEditing={() => this.renameBoard()} 89 | returnKeyType='done' /> 90 | } 91 | 92 | 93 | ) 94 | } 95 | 96 | archiveBoard() { 97 | this.props.deleteBoard({boardId: this.props.board.id}) 98 | axios.put(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.board.id}`, 99 | { 100 | archived: true, 101 | color: this.props.board.color, 102 | title: this.props.board.title 103 | }, 104 | { 105 | timeout: 8000, 106 | headers: { 107 | 'Content-Type': 'application/json', 108 | 'Authorization': this.props.token.value 109 | }, 110 | }) 111 | .then((resp) => { 112 | if (resp.status !== 200) { 113 | Toast.show({ 114 | type: 'error', 115 | text1: i18n.t('error'), 116 | text2: resp, 117 | }) 118 | this.props.addBoard(this.props.board) 119 | console.log('Error', resp) 120 | } else { 121 | console.log('Board archived') 122 | } 123 | }) 124 | .catch((error) => { 125 | Toast.show({ 126 | type: 'error', 127 | text1: i18n.t('error'), 128 | text2: error.message, 129 | }) 130 | this.props.addBoard(this.props.board) 131 | console.log(error) 132 | }) 133 | } 134 | 135 | deleteBoard() { 136 | this.props.deleteBoard({boardId: this.props.board.id}) 137 | axios.delete(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.board.id}`, 138 | { 139 | timeout: 8000, 140 | headers: { 141 | 'Content-Type': 'application/json', 142 | 'Authorization': this.props.token.value 143 | }, 144 | }) 145 | .then((resp) => { 146 | if (resp.status !== 200) { 147 | Toast.show({ 148 | type: 'error', 149 | text1: i18n.t('error'), 150 | text2: resp, 151 | }) 152 | this.props.addBoard(this.props.board) 153 | console.log('Error', resp) 154 | } else { 155 | console.log('Board deleted on server') 156 | } 157 | }) 158 | .catch((error) => { 159 | Toast.show({ 160 | type: 'error', 161 | text1: i18n.t('error'), 162 | text2: error.message, 163 | }) 164 | this.props.addBoard(this.props.board) 165 | console.log(error) 166 | }) 167 | } 168 | 169 | renameBoard() { 170 | const boardNameBackup = this.props.board.title 171 | this.props.renameBoard({ 172 | boardId: this.props.board.id, 173 | boardTitle: this.state.newBoardName 174 | }) 175 | axios.put(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.board.id}`, 176 | { 177 | archived: false, 178 | color: this.props.board.color, 179 | title: this.state.newBoardName 180 | }, 181 | { 182 | timeout: 8000, 183 | headers: { 184 | 'Content-Type': 'application/json', 185 | 'Authorization': this.props.token.value 186 | }, 187 | }) 188 | .then((resp) => { 189 | if (resp.status !== 200) { 190 | Toast.show({ 191 | type: 'error', 192 | text1: i18n.t('error'), 193 | text2: resp, 194 | }) 195 | this.props.renameBoard({ 196 | boardId: this.props.board.id, 197 | boardTitle: boardNameBackup 198 | }) 199 | console.log('Error', resp) 200 | } else { 201 | console.log('Board renamed') 202 | } 203 | }) 204 | .catch((error) => { 205 | Toast.show({ 206 | type: 'error', 207 | text1: i18n.t('error'), 208 | text2: error.message, 209 | }) 210 | this.props.renameBoard({ 211 | boardId: this.props.board.id, 212 | boardTitle: boardNameBackup 213 | }) 214 | console.log(error) 215 | }) 216 | } 217 | 218 | } 219 | 220 | // Connect to store 221 | const mapStateToProps = state => ({ 222 | server: state.server, 223 | theme: state.theme, 224 | token: state.token, 225 | }) 226 | const mapDispatchToProps = dispatch => ( 227 | bindActionCreators({ 228 | addBoard, 229 | deleteBoard, 230 | renameBoard, 231 | }, dispatch) 232 | ) 233 | export default connect( 234 | mapStateToProps, 235 | mapDispatchToProps 236 | )(Board) 237 | -------------------------------------------------------------------------------- /components/Card.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { ActionSheetIOS, Pressable, Text, TextInput, View } from 'react-native' 3 | import { DraxView } from 'react-native-drax' 4 | import Toast from 'react-native-toast-message' 5 | import { useDispatch, useSelector } from 'react-redux' 6 | import { addCard, deleteCard } from '../store/boardSlice' 7 | import AssigneeList from './AssigneeList' 8 | import LabelList from './LabelList' 9 | import { i18n } from '../i18n/i18n.js' 10 | import axios from 'axios' 11 | 12 | const dayjs = require('dayjs') 13 | var relativeTime = require('dayjs/plugin/relativeTime') 14 | dayjs.extend(relativeTime) 15 | 16 | // A component representing a card in a stack list 17 | const Card = ({card, navigation, route, stackId}) => { 18 | 19 | const [newCardName, setNewCardName] = useState('') 20 | const [editMode, setEditMode] = useState(false) 21 | const [timeoutId, setTimeoutId] = useState(-1); 22 | const theme = useSelector(state => state.theme) 23 | const server = useSelector(state => state.server) 24 | const token = useSelector(state => state.token) 25 | const dispatch = useDispatch() 26 | 27 | // Function to detect long press on card and open a context menu 28 | function cardPressedDown() { 29 | // Sets a timeout that will display a context menu if it is not canceled later by a drag of the card 30 | const id = setTimeout(() => { 31 | ActionSheetIOS.showActionSheetWithOptions( 32 | { 33 | options: [i18n.t("cancel"), i18n.t("rename"), i18n.t("delete")], 34 | destructiveButtonIndex: 2, 35 | cancelButtonIndex: 0, 36 | }, 37 | buttonIndex => { 38 | if (buttonIndex === 0) { 39 | // Cancel action 40 | } else if (buttonIndex === 1) { 41 | // Makes card's title editable 42 | setEditMode(true) 43 | } else if (buttonIndex === 2) { 44 | // Delete card 45 | removeCard() 46 | } 47 | } 48 | ) 49 | }, 500) 50 | setTimeoutId(id) 51 | } 52 | 53 | // Function to delete the card 54 | function removeCard() { 55 | console.log(`deleting card ${card.id}`) 56 | // Opportunistically deletes card from the store. We'll add it back if deleting it from the server fails. 57 | dispatch(deleteCard({ 58 | boardId: route.params.boardId, 59 | stackId, 60 | cardId: card.id, 61 | })) 62 | // Deletes card from server 63 | axios.delete(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${stackId}/cards/${card.id}`, 64 | { 65 | timeout: 8000, 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | 'Authorization': token.value 69 | }, 70 | }) 71 | .then((resp) => { 72 | if (resp.status !== 200) { 73 | Toast.show({ 74 | type: 'error', 75 | text1: i18n.t('error'), 76 | text2: resp, 77 | }) 78 | console.log('Error', resp) 79 | dispatch(addCard({ 80 | boardId: route.params.boardId, 81 | stackId, 82 | card, 83 | })) 84 | } 85 | }) 86 | .catch((error) => { 87 | Toast.show({ 88 | type: 'error', 89 | text1: i18n.t('error'), 90 | text2: error.message, 91 | }) 92 | console.log(error) 93 | dispatch(addCard({ 94 | boardId: route.params.boardId, 95 | stackId, 96 | card, 97 | })) 98 | }) 99 | } 100 | 101 | // Function to rename a card 102 | function changeCardTitle() { 103 | console.log(`Renaming card "${card.title}" to "${newCardName}"`) 104 | // Changes card title and keep a backup of its name in case something goes wrong 105 | const oldCardName = card.title 106 | // Opportunistically replaces card in the store. We'll replace it back if updating it from the server fails. 107 | dispatch(addCard({ 108 | boardId: route.params.boardId, 109 | stackId, 110 | card: { 111 | ...card, 112 | ...{ 113 | title: newCardName 114 | } 115 | } 116 | })) 117 | // Update the card on the server 118 | axios.put(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${stackId}/cards/${card.id}`, 119 | { 120 | ...card, 121 | ...{ 122 | title: newCardName 123 | } 124 | }, 125 | { 126 | timeout: 8000, 127 | headers: { 128 | 'Content-Type': 'application/json', 129 | 'Authorization': token.value 130 | }, 131 | }) 132 | .then((resp) => { 133 | if (resp.status !== 200) { 134 | Toast.show({ 135 | type: 'error', 136 | text1: i18n.t('error'), 137 | text2: resp, 138 | }) 139 | console.log('Error', resp) 140 | dispatch(addCard({ 141 | boardId: route.params.boardId, 142 | stackId: stackId, 143 | card: { 144 | ...card, 145 | ...{ 146 | title: oldCardName 147 | } 148 | } 149 | })) 150 | } else { 151 | console.log('Card renamed successfully') 152 | setEditMode(false) 153 | setNewCardName('') 154 | } 155 | }) 156 | .catch((error) => { 157 | Toast.show({ 158 | type: 'error', 159 | text1: i18n.t('error'), 160 | text2: error.message, 161 | }) 162 | console.log(error) 163 | card.title = oldCardName 164 | dispatch(addCard({ 165 | boardId: route.params.boardId, 166 | stackId, 167 | card: { 168 | ...card, 169 | ...{ 170 | title: oldCardName 171 | } 172 | } 173 | })) 174 | }) 175 | } 176 | 177 | return ( 178 | { 181 | // Navigates to the card's details page 182 | navigation.navigate('CardDetails',{ 183 | boardId: route.params.boardId, 184 | stackId, 185 | cardId: card.id 186 | }) 187 | }} > 188 | cardPressedDown()} 197 | onDrag={({dragTranslation}) => { 198 | if((dragTranslation.y > 5 || dragTranslation.y < -5) && timeoutId !== -1) { 199 | // if the card was actually moved, cancel opening the context menu 200 | clearTimeout(timeoutId) 201 | setTimeoutId(-1) 202 | } 203 | }} 204 | onDragEnd={() => { 205 | // Shows selected card's details when the user just clicked the card 206 | clearTimeout(timeoutId) 207 | setTimeoutId(-1) 208 | }} > 209 | 210 | { editMode ? 211 | { 216 | setEditMode(false) 217 | setNewCardName('') 218 | }} 219 | onChangeText={name => { 220 | setNewCardName(name) 221 | }} 222 | onSubmitEditing={() => changeCardTitle()} 223 | placeholder={card.title} 224 | returnKeyType='send' /> : 225 | 228 | {card.title} 229 | 230 | } 231 | 232 | 233 | 237 | 241 | 242 | { card.duedate && 243 | 244 | 248 | {dayjs().to(dayjs(card.duedate))} 249 | 250 | 251 | } 252 | 253 | 254 | 255 | 256 | ) 257 | 258 | } 259 | 260 | export default Card -------------------------------------------------------------------------------- /components/CommentPanel.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { useRoute } from '@react-navigation/native' 4 | import { addCard } from '../store/boardSlice' 5 | import { Modal, Pressable, TextInput, View } from 'react-native' 6 | import { Text } from 'react-native-elements' 7 | import axios from 'axios' 8 | import {Collapse,CollapseHeader, CollapseBody} from 'accordion-collapse-react-native' 9 | import * as Localization from 'expo-localization' 10 | import Toast from 'react-native-toast-message' 11 | import Icon from './Icon.js' 12 | import {i18n} from '../i18n/i18n.js' 13 | import {decode as atob} from 'base-64'; 14 | 15 | // The comment div that's displayed in the CardDetails view 16 | const CommentPanel = ({card, updateCard, showSpinner}) => { 17 | 18 | const theme = useSelector(state => state.theme) 19 | const server = useSelector(state => state.server) 20 | const token = useSelector(state => state.token) 21 | const user = atob(token.value.substring(6)).split(':')[0] 22 | const dispatch = useDispatch() 23 | 24 | const route = useRoute() 25 | 26 | const [showAddCommentModal, setShowAddCommentModal] = useState(false) // Used to show the add/edit comment modal 27 | const [newComment, setNewComment] = useState("") // Used to store comment entered by the user 28 | const [editedComment, setEditedComment] = useState(0) // Set when we are editing a comment 29 | 30 | // ComponentDidMount 31 | useEffect(() => { 32 | }, []) 33 | 34 | // Fetches card's comments 35 | const fetchCommentsIfNeeded = async () => { 36 | if (card.comments) { 37 | return 38 | } 39 | console.log('fetching comments from server') 40 | showSpinner(true) 41 | axios.get(server.value + `/ocs/v2.php/apps/deck/api/v1.0/cards/${route.params.cardId}/comments`, { 42 | timeout: 8000, 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | 'Authorization': token.value, 46 | 'OCS-APIRequest': true 47 | } 48 | }).then((resp) => { 49 | if (resp.status !== 200) { 50 | Toast.show({ 51 | type: 'error', 52 | text1: i18n.t('error'), 53 | text2: resp, 54 | }) 55 | showSpinner(false) 56 | console.log('Error', resp) 57 | } else { 58 | // Adds comments to card 59 | console.log('card comments retrieved from server') 60 | let comments = resp.data.ocs.data.map(comment => { 61 | return { 62 | 'id': comment.id, 63 | 'author': comment.actorDisplayName, 64 | 'authorId': comment.actorId, 65 | 'creationDate': new Date(comment.creationDateTime).toLocaleDateString(Localization.locale, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }), 66 | 'name': comment.message 67 | } 68 | }) 69 | updateCard({ 70 | ...card, 71 | ...{'comments': comments} 72 | }) 73 | showSpinner(false) 74 | } 75 | }).catch((error) => { 76 | Toast.show({ 77 | type: 'error', 78 | text1: i18n.t('error'), 79 | text2: error.message, 80 | }) 81 | showSpinner(false) 82 | console.log(error) 83 | }) 84 | } 85 | 86 | const addComment = () => { 87 | console.log('saving comment') 88 | axios.post(server.value + `/ocs/v2.php/apps/deck/api/v1.0/cards/${route.params.cardId}/comments`, 89 | { 90 | message: newComment 91 | }, 92 | { 93 | timeout: 8000, 94 | headers: { 95 | 'Content-Type': 'application/json', 96 | 'Authorization': token.value, 97 | 'OCS-APIRequest': true 98 | } 99 | }).then((resp) => { 100 | if (resp.status !== 200) { 101 | Toast.show({ 102 | type: 'error', 103 | text1: i18n.t('error'), 104 | text2: resp, 105 | }) 106 | console.log('Error', resp) 107 | } else { 108 | console.log('comment saved') 109 | 110 | // Saves card 111 | let cardWithNewComment 112 | let comment = { 113 | 'id': resp.data.ocs.data.id, 114 | 'author': resp.data.ocs.data.actorDisplayName, 115 | 'creationDate': new Date(resp.data.ocs.data.creationDateTime).toLocaleDateString(Localization.locale, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }), 116 | 'name': resp.data.ocs.data.message 117 | } 118 | if (card.commentsCount) { 119 | cardWithNewComment = { 120 | ...card, 121 | ...{ 122 | 'commentsCount': card.commentsCount + 1, 123 | 'comments': [ 124 | ...card.comments, 125 | ...[comment] 126 | ] 127 | } 128 | } 129 | } else { 130 | cardWithNewComment = { 131 | ...card, 132 | ... { 133 | 'commentsCount': 1, 134 | 'comments': [comment] 135 | } 136 | } 137 | } 138 | dispatch(addCard({ 139 | boardId: route.params.boardId, 140 | stackId: route.params.stackId, 141 | card: cardWithNewComment 142 | })) 143 | updateCard(cardWithNewComment) 144 | console.log('Card updated in store') 145 | 146 | // Resets state and hides modal 147 | setShowAddCommentModal(false) 148 | setNewComment('') 149 | } 150 | }).catch((error) => { 151 | Toast.show({ 152 | type: 'error', 153 | text1: i18n.t('error'), 154 | text2: error.message, 155 | }) 156 | console.log(error) 157 | }) 158 | } 159 | 160 | // Edits a comment 161 | const editComment = () => { 162 | console.log('updating comment') 163 | axios.put(server.value + `/ocs/v2.php/apps/deck/api/v1.0/cards/${route.params.cardId}/comments/${editedComment}`, 164 | { 165 | message: newComment 166 | }, 167 | { 168 | timeout: 8000, 169 | headers: { 170 | 'Content-Type': 'application/json', 171 | 'Authorization': token.value, 172 | 'OCS-APIRequest': true 173 | } 174 | }).then((resp) => { 175 | if (resp.status !== 200) { 176 | Toast.show({ 177 | type: 'error', 178 | text1: i18n.t('error'), 179 | text2: resp, 180 | }) 181 | console.log('Error', resp) 182 | } else { 183 | console.log('comment updated') 184 | 185 | // Updates card in frontend 186 | card.comments.forEach(c => { 187 | if (c.id === editedComment) { 188 | c.name = newComment 189 | } 190 | }) 191 | updateCard(card) 192 | // Updates card in store 193 | dispatch(addCard({ 194 | boardId: route.params.boardId, 195 | stackId: route.params.stackId, 196 | card 197 | })) 198 | console.log('Card updated in store') 199 | 200 | // Resets state and hides modal 201 | setShowAddCommentModal(false) 202 | setEditedComment(0) 203 | setNewComment('') 204 | } 205 | }).catch((error) => { 206 | Toast.show({ 207 | type: 'error', 208 | text1: i18n.t('error'), 209 | text2: error.message, 210 | }) 211 | console.log(error) 212 | }) 213 | } 214 | 215 | // Deletes a comment 216 | const deleteComment = (comment) => { 217 | console.log(`deleting comment ${comment.id}`) 218 | axios.delete(server.value + `/ocs/v2.php/apps/deck/api/v1.0/cards/${route.params.cardId}/comments/${comment.id}`, 219 | { 220 | timeout: 8000, 221 | headers: { 222 | 'OCS-APIREQUEST': true, 223 | 'Authorization': token.value 224 | } 225 | }).then((resp) => { 226 | if (resp.status !== 200) { 227 | Toast.show({ 228 | type: 'error', 229 | text1: i18n.t('error'), 230 | text2: resp, 231 | }) 232 | console.log('Error', resp) 233 | } else { 234 | // Saves card in store and updates frontend 235 | let newCard 236 | newCard = { 237 | ...card, 238 | ...{ 239 | 'commentsCount': card.commentsCount -1, 240 | 'comments': card.comments.filter(c => c.id !== comment.id) 241 | } 242 | } 243 | dispatch(addCard({ 244 | boardId: route.params.boardId, 245 | stackId: route.params.stackId, 246 | card: newCard 247 | })) 248 | updateCard(newCard) 249 | console.log('comment deleted') 250 | } 251 | }) 252 | } 253 | 254 | return ( 255 | 256 | { 261 | setShowAddCommentModal(false); 262 | }}> 263 | 264 | 265 | {i18n.t('addComment')} 266 | 267 | { setNewComment(comment) }} 272 | placeholder={i18n.t('comment')} 273 | /> 274 | { 276 | if (editedComment === 0) { 277 | addComment() 278 | } else { 279 | editComment() 280 | } 281 | }} > 282 | 283 | {i18n.t('save')} 284 | 285 | 286 | 287 | 288 | 291 | 292 | 293 | 294 | {i18n.t('comments') + ' (' + card.commentsCount + ')'} 295 | 296 | setShowAddCommentModal(true)}> 297 | 298 | 299 | 300 | 301 | 302 | {card.comments ? card.comments.map(comment => ( 303 | 304 | 305 | 306 | 307 | {comment.author} 308 | 309 | 310 | {comment.creationDate} 311 | 312 | 313 | 314 | {comment.name} 315 | 316 | 317 | 318 | { 320 | setEditedComment(comment.id) 321 | setNewComment(comment.name) 322 | setShowAddCommentModal(true) 323 | }} 324 | disabled={user!==comment.authorId} 325 | > 326 | 327 | 328 | deleteComment(comment)} disabled={user!==comment.authorId}> 329 | 330 | 331 | 332 | 333 | ) 334 | ) : null} 335 | 336 | 337 | 338 | ) 339 | 340 | } 341 | 342 | export default CommentPanel -------------------------------------------------------------------------------- /components/Icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createIconSetFromFontello } from '@expo/vector-icons' 3 | import fontelloConfig from '../assets/fonts/deck/config.json' 4 | 5 | const CreateIcon = createIconSetFromFontello( 6 | fontelloConfig, 7 | 'deck', 8 | 'deck.ttf' 9 | ) 10 | 11 | const Icon = ({ size = 24, name = 'right-open', style }) => { 12 | return 13 | } 14 | 15 | export default Icon -------------------------------------------------------------------------------- /components/LabelList.js: -------------------------------------------------------------------------------- 1 | //=============================================================================================================================================== 2 | // 3 | // LabelList: A component to show or modify a card's label 4 | // 5 | // This file is part of "Nextcloud Deck". 6 | // 7 | // "Nextcloud Deck" is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 8 | // as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 9 | // 10 | // "Nextcloud Deck" is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warrant 11 | // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License along with "Nextcloud Deck". If not, see . 14 | // 15 | //=============================================================================================================================================== 16 | 17 | import React, { useEffect, useState } from 'react' 18 | import { Text, View } from 'react-native' 19 | import DropDownPicker from 'react-native-dropdown-picker' 20 | import { useSelector } from 'react-redux' 21 | 22 | // LabelList is displayed into a scrollview and I'm assuming there won't ever be a ton of labels to display 23 | // See also https://hossein-zare.github.io/react-native-dropdown-picker-website/docs/advanced/list-modes#notes 24 | DropDownPicker.setListMode("SCROLLVIEW"); 25 | // To show selected labels (otherwise, only "x item(s) have been selected." is shown) 26 | DropDownPicker.setMode("BADGE"); 27 | 28 | const LabelList = ({editable, boardLabels, cardLabels, size='normal', udpateCardLabelsHandler}) => { 29 | 30 | const [open, setOpen] = useState(false); 31 | const [value, setValue] = useState(cardLabels?.map(item => item.id)); 32 | 33 | const theme = useSelector(state => state.theme) 34 | 35 | // Updates parent when value changes 36 | useEffect(() => { 37 | if (typeof udpateCardLabelsHandler !== 'undefined') { 38 | udpateCardLabelsHandler(value) 39 | } 40 | }, [value]) 41 | 42 | // default style. Will be overriden later depending on the size props 43 | var viewStyle = theme.cardDetailsLabel 44 | var textStyle = theme.cardDetailsLabelText 45 | 46 | // Computes the list of selectable labels for the DropDownPicker 47 | const items = boardLabels?.map(item => { 48 | return { 49 | containerStyle: { 50 | backgroundColor: '#' + item.color, 51 | borderRadius: 24, 52 | margin: 2, 53 | minWidth: 0, 54 | }, 55 | labelStyle: { 56 | fontWeight: "bold", 57 | justifyContent: 'center', 58 | textAlign: 'center', 59 | }, 60 | label: item.title, 61 | value: item.id 62 | } 63 | }) 64 | 65 | // Computes the colors of the selectable labels for the DropDownPicker 66 | const badgeColors = [] 67 | boardLabels?.forEach(item => { 68 | 69 | // This is how badgeColors are looked up in DropDownPicker 70 | const str = item.id.toString() 71 | var idx = 0 72 | for (let i=0; i 94 | ) 95 | } else { 96 | if (size === 'small') { 97 | viewStyle = theme.cardLabel 98 | textStyle = theme.cardLabelText 99 | } 100 | return ( 101 | 102 | {cardLabels?.map(label => ( 103 | 106 | 107 | {label.title} 108 | 109 | 110 | ))} 111 | 112 | ) 113 | } 114 | 115 | } 116 | 117 | export default LabelList -------------------------------------------------------------------------------- /components/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { ActivityIndicator, View } from 'react-native' 4 | import { Text } from 'react-native-elements' 5 | 6 | const Spinner = ({title}) => { 7 | 8 | const theme = useSelector(state => state.theme) 9 | 10 | return ( 11 | 12 | {title && 13 | 14 | {title} 15 | 16 | } 17 | 18 | 19 | 20 | ) 21 | 22 | } 23 | 24 | export default Spinner 25 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 0.52.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal" 9 | }, 10 | "simdev": { 11 | "extends": "development", 12 | "ios": { 13 | "simulator": true 14 | } 15 | }, 16 | "preview": { 17 | "distribution": "internal" 18 | }, 19 | "simpreview": { 20 | "extends": "preview", 21 | "ios": { 22 | "simulator": true 23 | } 24 | }, 25 | "production": {} 26 | }, 27 | "submit": { 28 | "production": {} 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /i18n/i18n.js: -------------------------------------------------------------------------------- 1 | import * as Localization from 'expo-localization'; 2 | import i18n from 'i18n-js'; 3 | import { en, fr, ru, ua, ca, de, it, sv, pl } from './languages'; 4 | 5 | i18n.fallbacks = true; 6 | i18n.defaultLocale = 'en'; 7 | i18n.translations = { en, fr, ru, ua, ca, de, it, sv, pl }; 8 | i18n.locale = Localization.locale; 9 | 10 | export {i18n}; 11 | -------------------------------------------------------------------------------- /i18n/languages.js: -------------------------------------------------------------------------------- 1 | export const en = { 2 | addComment: 'Add comment', 3 | addStack: 'Add stack', 4 | all: 'All', 5 | allBoards: 'All Boards', 6 | archive: 'Archive', 7 | assignees: 'Assignees', 8 | attachments: 'Attachments', 9 | attachmentSource: 'Attachment source', 10 | attachmentSourcePrompt: 'From which source do you want to select your attachment?', 11 | attachmentType: 'Attachment type', 12 | attachmentTypePrompt: 'Wich kind of attachment do you want to add?', 13 | back: 'Back', 14 | camera: 'Camera', 15 | cancel: 'Cancel', 16 | cardDetails: 'Card details', 17 | comment: 'Comment', 18 | comments: 'Comments', 19 | create : 'Create', 20 | createBoard: 'Create board', 21 | createCard : 'Create card', 22 | createStack : 'Create stack', 23 | dark: 'Dark', 24 | delete : 'Delete', 25 | deleteStack: 'Delete stack', 26 | description : 'Description', 27 | descriptionOptional: 'Description (optional)', 28 | document: 'Document', 29 | dueDate : 'Due date', 30 | edit : 'Edit', 31 | error: 'Error', 32 | generalSettings: 'General settings', 33 | image: 'Image', 34 | labels: 'Labels', 35 | light: 'Light', 36 | loading: 'Loading...', 37 | logout: 'Logout', 38 | manageBoardLabels: 'Manage board labels', 39 | manageBoardMembers: 'Manage board members', 40 | moveCard : 'Move card', 41 | no: 'No', 42 | noStack : 'This board has no stack. Start working by first creating a stack.', 43 | newBoardHint : 'Type the name of a board to create one', 44 | newCardHint : 'Type here the title of your card', 45 | newStackHint : 'Type here the name of a new list', 46 | os: 'OS', 47 | photoGallery: 'Photo gallery', 48 | rename : 'Rename', 49 | renameStack : 'Rename stack', 50 | save : 'Save', 51 | saving: 'Saving...', 52 | search: 'Search', 53 | selectedOption: 'Selected option', 54 | sendAttachments: 'Send attachments?', 55 | sendAttachmentsPrompt: 'Do you also want to send the card\'s attachments?', 56 | sendByMail: 'Send by email', 57 | setDueDate : 'Set due date', 58 | setUrl : 'Please enter the URL of your Nextcloud server', 59 | signIn : 'Sign In', 60 | subscriptions: 'Subscriptions', 61 | subscribe: 'Subscribe', 62 | theme : 'Theme', 63 | title : 'Title', 64 | unauthorizedToEditCard : 'You are not authorized to edit this card', 65 | useAppWithoutAds: 'Use this app without ads', 66 | userSubscribed: 'You are subscribed to Nextcloud Deck without ad', 67 | yes: 'Yes' 68 | } 69 | 70 | export const fr = { 71 | addComment: 'Ajout de commentaire', 72 | addStack: 'Ajouter une liste', 73 | all: 'Tous', 74 | allBoards: 'Tous les tableaux', 75 | archive: 'Archiver', 76 | assignees: 'Assignés', 77 | attachments: 'Attachements', 78 | attachmentSource: 'Source', 79 | attachmentSourcePrompt: 'D\'où voulez vous ajouter votre attachement?', 80 | attachmentType: 'Type d\'attachement', 81 | attachmentTypePrompt: 'Quelle sorte d\'attachement voulez-vous ajouter?', 82 | back: 'Retour', 83 | cancel: 'Annuler', 84 | cardDetails: 'Détail de la carte', 85 | comment: 'Commentaire', 86 | comments: 'Commentaires', 87 | create : 'Créer', 88 | createBoard: 'Créer un tableau', 89 | createCard : 'Créer une carte', 90 | createStack : 'Créer une liste', 91 | dark: 'Foncé', 92 | delete : 'Supprimer', 93 | deleteStack: 'Supprimer la liste', 94 | description : 'Description', 95 | descriptionOptional: 'Description (optionnel)', 96 | document: 'Document', 97 | dueDate : 'Echéance', 98 | edit : 'Modifier', 99 | error: 'Erreur', 100 | generalSettings: 'Paramètres généraux', 101 | image: 'Image', 102 | labels: 'Etiquettes', 103 | light: 'Clair', 104 | loading: 'Chargement...', 105 | logout: 'Déconnecter', 106 | manageBoardLabels: 'Gérer les étiquettes du tableau', 107 | manageBoardMembers: 'Gérer les membres du tableau', 108 | moveCard : 'Déplacer la carte', 109 | no: 'Non', 110 | noStack : 'Ce tableau n\'a pas de liste. Commencez par en créer une.', 111 | newBoardHint: 'Tapez un nom pour créer un tableau', 112 | newCardHint: 'Tapez ici le titre de votre carte pour la créer', 113 | newStackHint : 'Entrez le nom de votre nouvelle liste ici', 114 | os: 'OS', 115 | rename: 'Renommer', 116 | renameStack: 'Renommer la liste', 117 | save : 'Sauvegarder', 118 | saving: 'Sauvegarde...', 119 | search: 'Chercher', 120 | selectedOption: 'Option Selectionnée', 121 | sendAttachments: 'Envoyer les attachements?', 122 | sendAttachmentsPrompt: 'Voulez vous aussi envoyer les attachements de la carte?', 123 | sendByMail: 'Envoyer par email', 124 | setDueDate : 'Définir une échéance', 125 | setUrl : 'Veuillez entrer l\'URL de votre serveur Nextcloud', 126 | signIn: 'S\'identifier', 127 | subscriptions: 'Abonnements', 128 | subscribe: 'Souscrire', 129 | theme : 'Theme', 130 | title : 'Titre', 131 | unauthorizedToEditCard : 'Vous n\'êtes pas autorisé à modifier cette carte', 132 | useAppWithoutAds: 'Utilisez cette app sans publicité', 133 | userSubscribed: 'Vous êtes abonné à Nextcloud Deck sans publicité', 134 | yes: 'Oui' 135 | } 136 | 137 | export const ru = { 138 | create : 'Создать', 139 | createCard : 'Создать карточку', 140 | delete : 'Удалить', 141 | description : 'Описание', 142 | dueDate : 'Дата исполнения', 143 | edit : 'Редактировать', 144 | logout: 'Выйти', 145 | moveCard : 'Переместить карточку', 146 | save : 'Сохранить', 147 | setDueDate : 'Задать дату исполнения', 148 | setUrl : 'Введите URL-адрес вашего сервера Nextcloud', 149 | signIn: 'Sign In', 150 | title : 'Заголовок', 151 | } 152 | 153 | export const ua = { 154 | create : 'Створити', 155 | createCard : 'Створити картку', 156 | delete : 'Видалити', 157 | description : 'Опис', 158 | dueDate : 'Дата виконання', 159 | edit : 'Редагувати', 160 | logout: 'Вийти', 161 | moveCard : 'Перемістити картку', 162 | save : 'Зберегти', 163 | setDueDate : 'Задати дату виконання', 164 | setUrl : 'Введіть URL-адресу вашого серверу Nextcloud', 165 | signIn: 'Sign In', 166 | title : 'Заголовок', 167 | } 168 | 169 | export const ca = { 170 | create : 'Crear', 171 | createBoard: 'Crear tauler', 172 | createCard : 'Crear targeta', 173 | createStack : 'Crear llista', 174 | delete : 'Esborrar', 175 | description : 'Descripció', 176 | dueDate : 'Data de venciment', 177 | edit : 'Editar', 178 | logout: 'Tancar la sessió', 179 | moveCard : 'Moure targeta', 180 | noStack : 'Aquest tauler no té llistes. Comenceu a treballar creant-ne una.', 181 | newBoardHint: 'Escriviu el nom d\'un tauler per creear-ne un', 182 | newStackHint : 'Escriviu el nom de la nova llista', 183 | save : 'Desar', 184 | setDueDate : 'Inserir data de venciment', 185 | setUrl : 'Si us plau, escriviu l\'adreça del vostre servidor Nextcloud', 186 | signIn: 'Iniciar la sessió', 187 | title : 'Títol', 188 | } 189 | 190 | export const de = { 191 | addStack: 'Liste hinzufügen', 192 | all: 'Alle', 193 | allBoards: 'Alle Boards', 194 | archive: 'Archivieren', 195 | assignees: 'Beauftragte', 196 | back: 'Zurück', 197 | cancel: 'Abbrechen', 198 | cardDetails: 'Kartendetails', 199 | create : 'Hinzufügen', 200 | createBoard: 'Board hinzufügen', 201 | createCard : 'Karte hinzufügen', 202 | createStack : 'Liste hinzufügen', 203 | delete : 'Löschen', 204 | deleteStack: 'Liste löschen', 205 | description : 'Beschreibung: ', 206 | dueDate : 'Ablaufdatum: ', 207 | edit : 'Bearbeiten', 208 | error: 'Fehler', 209 | labels: 'Labels', 210 | loading: 'Lade...', 211 | logout: 'Abmelden', 212 | moveCard : 'Karte verschieben', 213 | noStack : 'Dieses Board hat keine Liste. Bitte lege zuerst eine Liste an.', 214 | newBoardHint: 'Gib den Namen des Boards an, um es anzulegen', 215 | newCardHint : 'Geben Sie hier den Titel der Karte ein', 216 | newStackHint : 'Gib den Namen der neuen Liste ein', 217 | rename: 'Umbenennen', 218 | renameStack : 'Liste umbenennen', 219 | save : 'Speichern', 220 | saving: 'Speichere...', 221 | setDueDate : 'Ablaufdatum setzen', 222 | setUrl : 'Bitte gib die URL Deines Nextcloud Servers an', 223 | signIn: 'Anmelden', 224 | title : 'Titel: ', 225 | unauthorizedToEditCard : 'Sie sind nicht berechtigt, diese Karte zu bearbeiten' 226 | } 227 | 228 | export const it = { 229 | addComment: 'Aggiungi commento', 230 | addStack: 'Aggiungi elenco', 231 | all: 'Tutti', 232 | allBoards: 'Tutte le lavagne', 233 | archive: 'Archivia', 234 | assignees: 'Assegnato a', 235 | attachments: 'Allegati', 236 | attachmentSource: 'Sorgente allegato', 237 | attachmentSourcePrompt: 'Da quale sorgente vuoi selezionare l\'allegato?', 238 | attachmentType: 'Tipo di allenato', 239 | attachmentTypePrompt: 'Quale tipo di allegato vuoi aggiungere?', 240 | back: 'Indietro', 241 | camera: 'Fotocamera', 242 | cancel: 'Annulla', 243 | cardDetails: 'Dettagli scheda', 244 | comment: 'Commento', 245 | comments: 'Commenti', 246 | create : 'Aggiungi', 247 | createBoard: 'Aggiungi lavagna', 248 | createCard : 'Aggiungi scheda', 249 | createStack : 'Aggiungi elenco', 250 | delete : 'Elimina', 251 | deleteStack: 'Elimina elenco', 252 | description : 'Descrizione: ', 253 | descriptionOptional: 'Descrizione (opzionale)', 254 | document: 'Documento', 255 | dueDate : 'Data di scadenza: ', 256 | edit : 'Modifica', 257 | error: 'Errore', 258 | image: 'Immagine', 259 | labels: 'Etichette', 260 | loading: 'Caricamento...', 261 | logout: 'Esci', 262 | manageBoardLabels: 'Gestisci etichette lavagna', 263 | manageBoardMembers: 'Gestisci utenti lavagna', 264 | moveCard : 'Sposta scheda', 265 | no: 'No', 266 | noStack : 'Questa lavagna non ha nessun elenco. Inizia creando un elenco.', 267 | newBoardHint: 'Inserisci il nome della lavagna per crearla', 268 | newCardHint : 'Inserisci qui il titolo della scheda', 269 | newStackHint : 'Inserisci il nome del nuovo elenco', 270 | photoGallery: 'Galleria foto', 271 | rename: 'Rinomina', 272 | renameStack : 'Rinomina elenco', 273 | save : 'Salva', 274 | saving: 'Salvataggio...', 275 | search: 'Cerca', 276 | sendAttachments: 'Invia allegati?', 277 | sendAttachmentsPrompt: 'Vuoi inviare anche gli allegati della scheda?', 278 | sendByMail: 'Invia via email', 279 | setDueDate : 'Imposta una data di scadenza', 280 | setUrl : 'Inserisci la URL del tuo server Nextcloud', 281 | signIn: 'Accedi', 282 | theme : 'Tema', 283 | title : 'Titolo: ', 284 | unauthorizedToEditCard : 'Non sei autorizzato a modificare questa scheda', 285 | yes: 'Si' 286 | } 287 | 288 | export const sv = { 289 | addComment: 'Lägg till Kommentar', 290 | addStack: 'Lägg till lista', 291 | all: 'Alla', 292 | allBoards: 'Alla tavlor', 293 | archive: 'Arkiv', 294 | assignees: 'Uppdragstagaren', 295 | attachments: 'Bilagor', 296 | back: 'Tillbaka', 297 | cancel: 'Avbryt', 298 | cardDetails: 'Kortinnehåll', 299 | comment: 'Komentar', 300 | comments: 'Kommentarer', 301 | create : 'Skapa', 302 | createBoard: 'Skapa tavla', 303 | createCard : 'Skapa kort', 304 | createStack : 'Skapa lista', 305 | delete : 'Ta bort', 306 | deleteStack: 'Ta bort lista', 307 | description : 'Beskrivning', 308 | descriptionOptional: 'Beskrivning (frivillig)', 309 | dueDate : 'förfallodatum', 310 | edit : 'Ändra', 311 | error: 'Fel', 312 | labels: 'Etiketter', 313 | loading: 'Laddar...', 314 | logout: 'Logga ut', 315 | manageBoardLabels: 'Hantera taveletiketter', 316 | manageBoardMembers: 'Hantera tavelmedlemmar', 317 | moveCard : 'Flytta kort', 318 | noStack : 'Det här tavlan har ingen stack. Börja arbeta genom att först skapa en stack.', 319 | newBoardHint : 'Skriv namnet på en tavlan för att skapa en', 320 | newCardHint : 'Skriv in titeln på ditt kort här', 321 | newStackHint : 'Skriv namnet på en ny lista här', 322 | rename : 'Byt namn', 323 | renameStack : 'Byt namn på lista', 324 | save : 'Spara', 325 | saving: 'Sparar...', 326 | search: 'Sök', 327 | setDueDate : 'Ställ in förfallodatum', 328 | setUrl : 'Vänligen ange URL:en till din Nextcloud-server', 329 | signIn : 'Logga in', 330 | subscriptions: 'Prenumeration', 331 | subscribe: 'Prenumerera', 332 | theme : 'Tema', 333 | title : 'Titel', 334 | unauthorizedToEditCard : 'Du är inte behörig att redigera detta kort' 335 | } 336 | 337 | export const pl = { 338 | addComment: 'Dodaj komentarz', 339 | addStack: 'Dodaj listę', 340 | all: 'Wszystko', 341 | allBoards: 'Wszystkie tablice', 342 | archive: 'Archiwum', 343 | assignees: 'Przypisani użytkownicy', 344 | attachments: 'Załączniki', 345 | back: 'Wstecz', 346 | cancel: 'Anuluj', 347 | cardDetails: 'Szczegóły karty', 348 | comment: 'Komentarz', 349 | comments: 'Komentarze', 350 | create : 'Stwórz', 351 | createBoard: 'Stwórz tablicę', 352 | createCard : 'Stwórz kartę', 353 | createStack : 'Stwórz listę', 354 | delete : 'Usuń', 355 | deleteStack: 'Usuń listę', 356 | description : 'Opis', 357 | descriptionOptional: 'Opis (opcjonalnie)', 358 | dueDate : 'Termin', 359 | edit : 'Edytuj', 360 | error: 'Błąd', 361 | labels: 'Etykiety', 362 | loading: 'Ładowanie...', 363 | logout: 'Wyloguj', 364 | manageBoardLabels: 'Zarządzaj etykietami tej tablicy', 365 | manageBoardMembers: 'Zarządzaj użytkownikami tej tablicy', 366 | moveCard : 'Przenieś kartę', 367 | noStack : 'Ta tablica nie ma żadnej listy - stwórz listę aby rozpocząć', 368 | newBoardHint : 'Wpisz nazwę nowej tablicy', 369 | newCardHint : 'Wpisz nazwę nowej karty', 370 | newStackHint : 'Wpisz nazwę nowej listy', 371 | rename : 'Zmień nazwę', 372 | renameStack : 'Zmień nazwę listy', 373 | save : 'Zapisz', 374 | saving: 'Zapisywanie...', 375 | search: 'Szukaj', 376 | setDueDate : 'Ustaw termin', 377 | setUrl : 'Wpisz adres URL swojego serwera nextcloud', 378 | signIn : 'Zaloguj', 379 | theme : 'Motyw', 380 | title : 'Tytuł', 381 | unauthorizedToEditCard : 'Nie masz uprawnień by edytować tą kartę' 382 | } 383 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { registerRootComponent } from 'expo'; 4 | import 'expo-dev-client'; 5 | import App from './App'; 6 | import store from './store/store'; 7 | 8 | class AppProvider extends React.Component { 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | ) 15 | } 16 | } 17 | 18 | registerRootComponent(AppProvider); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "expo start --dev-client", 4 | "android": "expo run:android", 5 | "ios": "expo run:ios", 6 | "web": "expo start --web", 7 | "eject": "expo eject" 8 | }, 9 | "dependencies": { 10 | "@adapty/react-native-ui": "^2.1.0", 11 | "@react-native-async-storage/async-storage": "1.23", 12 | "@react-native-community/datetimepicker": "~7.6.1", 13 | "@react-native-segmented-control/segmented-control": "2.5", 14 | "@react-navigation/elements": "^1.2.1", 15 | "@react-navigation/native": "^6.0.13", 16 | "@react-navigation/stack": "^6.3.2", 17 | "@reduxjs/toolkit": "^2.2.5", 18 | "accordion-collapse-react-native": "^1.1.1", 19 | "axios": "^1.7.2", 20 | "base-64": "^1.0.0", 21 | "dayjs": "^1.11.3", 22 | "expo": "^50.0.7", 23 | "expo-dev-client": "~3.3.8", 24 | "expo-device": "~5.9.3", 25 | "expo-document-picker": "~11.10.1", 26 | "expo-file-system": "~16.0.6", 27 | "expo-image-picker": "~14.7.1", 28 | "expo-linking": "~6.2.2", 29 | "expo-localization": "~14.8.3", 30 | "expo-mail-composer": "~12.7.1", 31 | "expo-screen-orientation": "~6.4.1", 32 | "expo-splash-screen": "~0.26.4", 33 | "expo-status-bar": "~1.11.1", 34 | "expo-system-ui": "~2.9.3", 35 | "i18n-js": "^3.9.2", 36 | "markdown-it-task-lists": "^2.1.1", 37 | "react": "18.2.0", 38 | "react-dom": "18.2.0", 39 | "react-native": "0.73.6", 40 | "react-native-adapty": "~2.10.0", 41 | "react-native-bouncy-checkbox": "^2.1.1", 42 | "react-native-drax": "~0.10.0", 43 | "react-native-dropdown-picker": "^5.3.0", 44 | "react-native-elements": "^3.4.2", 45 | "react-native-file-viewer": "^2.1.5", 46 | "react-native-floating-action": "^1.22.0", 47 | "react-native-gesture-handler": "~2.14.0", 48 | "react-native-google-mobile-ads": "^12.6.0", 49 | "react-native-markdown-display": "^7.0.2", 50 | "react-native-material-menu": "^1.2.0", 51 | "react-native-pager-view": "6.2.3", 52 | "react-native-reanimated": "~3.6.2", 53 | "react-native-safe-area-context": "4.8.2", 54 | "react-native-screens": "~3.29.0", 55 | "react-native-tab-view": "^3.3.0", 56 | "react-native-toast-message": "^2.0.2", 57 | "react-native-vector-icons": "^8.1.0", 58 | "react-native-web": "^0.19.12", 59 | "react-native-webview": "13.6.4", 60 | "react-redux": "^9.1.2", 61 | "redux": "^5.0.1", 62 | "tslib": "^2.6.3" 63 | }, 64 | "devDependencies": { 65 | "@babel/core": "^7.24.7", 66 | "@babel/plugin-transform-optional-chaining": "^7.24.7", 67 | "eslint": "^8.57.0", 68 | "eslint-plugin-react": "^7.34.2" 69 | }, 70 | "private": true, 71 | "name": "deck-react-native", 72 | "packageManager": "yarn@3.2.1" 73 | } 74 | -------------------------------------------------------------------------------- /store/boardSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | export const boardSlice = createSlice({ 4 | name: 'boards', 5 | initialState: { 6 | value: {}, 7 | }, 8 | reducers: { 9 | addBoard: (state, action) => { 10 | state.value[action.payload.id] = action.payload 11 | }, 12 | addCard: (state, action) => { 13 | state.value[action.payload.boardId].stacks.find(oneStack => oneStack.id === action.payload.stackId).cards[action.payload.card.id] = action.payload.card 14 | }, 15 | addLabel: (state, action) => { 16 | // Removes the label if it's already there 17 | let labels = state.value[action.payload.boardId].labels.filter(label => label.id !== action.payload.label.id) 18 | // Adds the label 19 | labels.push(action.payload.label) 20 | // Saves the new labels array 21 | state.value[action.payload.boardId].labels = labels 22 | }, 23 | addUser: (state, action) => { 24 | // Removes the user if it's already there 25 | let users = state.value[action.payload.boardId].users.filter(user => user.uid !== action.payload.user.uid) 26 | // Adds the user 27 | users.push(action.payload.user) 28 | // Saves the new labels array 29 | state.value[action.payload.boardId].users = users 30 | }, 31 | addStack: (state, action) => { 32 | // Stores cards as an object indexed by cards' id rather than in an array 33 | const cards = action.payload.stack.cards 34 | action.payload.stack.cards = {} 35 | if (typeof cards !== 'undefined') { 36 | cards.forEach(card => { 37 | action.payload.stack.cards[card.id] = card 38 | }) 39 | } 40 | // Filter out existing stack with same id 41 | if (state.value[action.payload.boardId].stacks?.length) { 42 | state.value[action.payload.boardId].stacks = state.value[action.payload.boardId].stacks.filter(oneStack => oneStack.id !== action.payload.stack.id) 43 | } else { 44 | // Prepare empty stack array 45 | state.value[action.payload.boardId].stacks = []; 46 | } 47 | // Adds stack 48 | state.value[action.payload.boardId].stacks.push(action.payload.stack) 49 | // Sort stacks by order 50 | state.value[action.payload.boardId].stacks.sort((a, b) => a.order - b.order) 51 | 52 | return state 53 | }, 54 | deleteAllBoards: (state) => { 55 | state.value = {} 56 | }, 57 | deleteBoard: (state, action) => { 58 | delete state.value[action.payload.boardId] 59 | console.log('Board ' + action.payload.boardId + ' removed from store') 60 | }, 61 | deleteCard: (state, action) => { 62 | delete state.value[action.payload.boardId].stacks.find(oneStack => oneStack.id === action.payload.stackId).cards[action.payload.cardId] 63 | }, 64 | deleteStack: (state, action) => { 65 | const stackIndex = state.value[action.payload.boardId].stacks.findIndex(stack => stack.id === action.payload.stackId) 66 | delete state.value[action.payload.boardId].stacks.splice(stackIndex, 1) 67 | }, 68 | moveCard: (state, action) => { 69 | const card = state.value[action.payload.boardId].stacks.find(oneStack => oneStack.id === action.payload.oldStackId)?.cards[action.payload.cardId] 70 | state.value[action.payload.boardId].stacks.find(oneStack => oneStack.id === action.payload.newStackId).cards[action.payload.cardId] = card 71 | delete state.value[action.payload.boardId].stacks.find(oneStack => oneStack.id === action.payload.oldStackId).cards[action.payload.cardId] 72 | }, 73 | renameBoard: (state, action) => { 74 | state.value[action.payload.boardId].title = action.payload.boardTitle 75 | }, 76 | renameStack: (state, action) => { 77 | console.log(action.payload) 78 | state.value[action.payload.boardId].stacks.find(oneStack => oneStack.id === action.payload.stackId).title = action.payload.stackTitle 79 | } 80 | } 81 | }) 82 | 83 | export const { addBoard, addCard, addLabel, addStack, addUser, deleteAllBoards, deleteBoard, deleteCard, deleteStack, moveCard, renameBoard, renameStack } = boardSlice.actions 84 | 85 | export default boardSlice.reducer 86 | -------------------------------------------------------------------------------- /store/colorSchemeSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | export const colorSchemeSlice = createSlice({ 4 | name: 'colorScheme', 5 | initialState: { 6 | value: 'os', 7 | }, 8 | reducers: { 9 | setColorScheme: (state, action) => { 10 | console.log('saving colorscheme: ', action.payload) 11 | state.value = action.payload 12 | }, 13 | }, 14 | }) 15 | 16 | export const { setColorScheme } = colorSchemeSlice.actions 17 | 18 | export default colorSchemeSlice.reducer 19 | -------------------------------------------------------------------------------- /store/serverSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | export const serverSlice = createSlice({ 4 | name: 'server', 5 | initialState: { 6 | value: null, 7 | }, 8 | reducers: { 9 | setServer: (state, action) => { 10 | state.value = action.payload 11 | } 12 | } 13 | }) 14 | 15 | export const { setServer } = serverSlice.actions 16 | 17 | export default serverSlice.reducer -------------------------------------------------------------------------------- /store/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import boardReducer from './boardSlice' 3 | import colorSchemeReducer from './colorSchemeSlice' 4 | import serverReducer from './serverSlice' 5 | import themeReducer from './themeSlice' 6 | import tokenReducer from './tokenSlice' 7 | 8 | export default configureStore({ 9 | reducer: { 10 | boards: boardReducer, 11 | colorScheme: colorSchemeReducer, 12 | server: serverReducer, 13 | theme: themeReducer, 14 | token: tokenReducer 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /store/themeSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import createStyles from '../styles/base' 3 | 4 | export const themeSlice = createSlice({ 5 | name: 'theme', 6 | initialState: createStyles('light'), 7 | reducers: { 8 | setTheme: (state, action) => { 9 | console.log('saving theme: ', action.payload) 10 | const newStyle = createStyles(action.payload) 11 | return {...state, ...newStyle} 12 | }, 13 | }, 14 | }) 15 | 16 | export const { setTheme } = themeSlice.actions 17 | 18 | export default themeSlice.reducer 19 | -------------------------------------------------------------------------------- /store/tokenSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | export const tokenSlice = createSlice({ 4 | name: 'token', 5 | initialState: { 6 | value: null, 7 | }, 8 | reducers: { 9 | setToken: (state, action) => { 10 | state.value = action.payload 11 | } 12 | } 13 | }) 14 | 15 | export const { setToken } = tokenSlice.actions 16 | 17 | export default tokenSlice.reducer -------------------------------------------------------------------------------- /styles/base.js: -------------------------------------------------------------------------------- 1 | import {StyleSheet, Dimensions} from 'react-native' 2 | 3 | // ----------- Basics ----------- // 4 | export const dimensions = { 5 | fullHeight: Dimensions.get('window').height, 6 | fullWidth: Dimensions.get('window').width 7 | } 8 | 9 | export const getColors = (theme) => { 10 | const palette = { 11 | light: { 12 | bg: '#fff', 13 | bgDefault: '#f2f2f2', 14 | iconDisabled: '#a0a0a0', 15 | iconEnabled: '#505050', 16 | lightText: '#505050', 17 | text: '#000', 18 | textReverted: '#d8d8d8', 19 | border: '#E5E5E5', 20 | // blueish 21 | bgInteract: '#D8E6FF', 22 | textInteract: '#005DFF', 23 | // reddish 24 | bgDestruct: '#FFCAC9', 25 | textDestruct: '#FF0300', 26 | }, 27 | dark: { 28 | bg: '#181818', 29 | bgDefault: '#222', 30 | iconDisabled: '#202020', 31 | iconEnabled: '#505050', 32 | lightText: '#505050', 33 | text: '#d8d8d8', 34 | textReverted: '#000', 35 | border: '#2a2a2a', 36 | // blueish 37 | bgInteract: '#001B4A', 38 | textInteract: '#D8E6FF', 39 | // reddish 40 | bgDestruct: '#3D0100', 41 | textDestruct: '#FFCAC9', 42 | } 43 | } 44 | return palette[theme] 45 | } 46 | 47 | export const padding = { 48 | xxs: 2, 49 | xs: 4, 50 | s: 8, 51 | m: 16, 52 | l: 24, 53 | xl: 32 54 | } 55 | 56 | export const fonts = { 57 | xs: 12, 58 | s: 15, 59 | m: 17, 60 | l: 19, 61 | xl: 23, 62 | } 63 | 64 | export const dropShadow = { 65 | shadowColor: '#000', 66 | shadowOffset: {width: 0, height: padding.xs}, 67 | shadowOpacity: 0.05, 68 | shadowRadius: padding.s, 69 | elevation: 3, 70 | } 71 | 72 | const containerStyles = { 73 | padding: padding.m, 74 | } 75 | 76 | const baseStyles = (theme) => { 77 | const colors = getColors(theme) 78 | return { 79 | attachment: { 80 | color: colors.text, 81 | marginVertical: padding.s 82 | }, 83 | attachmentAuthor: { 84 | color: colors.text, 85 | fontSize: fonts.s 86 | }, 87 | attachmentCreationDate: { 88 | color: colors.text, 89 | fontSize: fonts.xs, 90 | marginLeft: padding.s 91 | }, 92 | attachmentHeader: { 93 | alignItems: 'baseline', 94 | flexDirection: 'row', 95 | marginBottom: padding.xs 96 | }, 97 | attachmentName: { 98 | color: colors.text, 99 | }, 100 | boardDetailsContainer: { 101 | ...containerStyles, 102 | paddingTop: 0, 103 | }, 104 | boardMenu: { 105 | flex: 1, 106 | flexDirection: 'row', 107 | alignItems: 'center', 108 | justifyContent: 'flex-end', 109 | }, 110 | button: { 111 | width: '100%', 112 | borderRadius: padding.m, 113 | padding: padding.m, 114 | marginVertical: padding.s, 115 | backgroundColor: colors.bgInteract, 116 | }, 117 | buttonDestruct: { 118 | backgroundColor: colors.bgDestruct, 119 | }, 120 | buttonTitle: { 121 | textAlign: 'center', 122 | fontSize: fonts.l, 123 | fontWeight: '600', 124 | color: colors.textInteract, 125 | }, 126 | buttonTitleDestruct: { 127 | color: colors.textDestruct, 128 | }, 129 | card: { 130 | backgroundColor: colors.bg, 131 | borderRadius: padding.m, 132 | padding: padding.m, 133 | marginTop: padding.m, 134 | flexDirection: 'row', 135 | alignItems: 'center', 136 | ...dropShadow, 137 | }, 138 | cardColor: { 139 | width: padding.m, 140 | height: padding.m, 141 | borderRadius: padding.m / 2, 142 | marginRight: padding.m 143 | }, 144 | cardDetailsLabel: { 145 | borderRadius: padding.l, 146 | marginRight: padding.s, 147 | minWidth: 0, 148 | paddingLeft: padding.s, 149 | paddingRight: padding.s, 150 | }, 151 | cardDetailsLabelText: { 152 | fontSize: fonts.m, 153 | justifyContent: 'center', 154 | textAlign: 'center', 155 | }, 156 | cardLabel: { 157 | borderRadius: padding.s, 158 | marginRight: padding.xxs, 159 | minWidth: 0, 160 | paddingLeft: padding.xs, 161 | paddingRight: padding.xs, 162 | }, 163 | cardLabelContainer: { 164 | flex: 1, 165 | flexDirection: 'row', 166 | marginTop: padding.xs, 167 | }, 168 | cardLabelText: { 169 | fontSize: fonts.xs, 170 | justifyContent: 'center', 171 | textAlign: 'center', 172 | }, 173 | cardTitle: { 174 | color: colors.text, 175 | flex: 1, 176 | fontSize: fonts.xl 177 | }, 178 | comment: { 179 | color: colors.text, 180 | marginBottom: padding.s 181 | }, 182 | commentAuthor: { 183 | color: colors.text, 184 | fontSize: fonts.m 185 | }, 186 | commentCreationDate: { 187 | color: colors.text, 188 | fontSize: fonts.xs, 189 | marginLeft: padding.s 190 | }, 191 | commentHeader: { 192 | alignItems: 'baseline', 193 | flexDirection: 'row', 194 | marginBottom: padding.xs 195 | }, 196 | container: { 197 | containerStyles, 198 | }, 199 | descriptionInput: { 200 | minHeight: 120, 201 | }, 202 | dueDate: { 203 | flex: 1, 204 | justifyContent: 'flex-end', 205 | alignItems: 'flex-end', 206 | paddingTop: 5, 207 | }, 208 | dueDateText: { 209 | fontWeight: 'bold', 210 | fontSize: fonts.xs 211 | }, 212 | icon: { 213 | color: colors.text, 214 | }, 215 | iconDisabled: { 216 | color: colors.iconDisabled 217 | }, 218 | iconEnabled: { 219 | color: colors.iconEnabled 220 | }, 221 | iconGrey: { 222 | color: colors.lightText 223 | }, 224 | iconsMenu: { 225 | flexDirection: 'row', 226 | }, 227 | input: { 228 | color: colors.text, 229 | width: '100%', 230 | flexDirection: 'row', 231 | backgroundColor: colors.bg, 232 | borderColor: colors.border, 233 | fontSize: fonts.m, 234 | borderWidth: 1, 235 | borderRadius: padding.s, 236 | marginVertical: padding.s, 237 | padding: padding.m, 238 | }, 239 | inputReadMode: { 240 | color: colors.text, 241 | width: '100%', 242 | flexDirection: 'row', 243 | fontSize: fonts.m, 244 | }, 245 | inputButton: { 246 | display: 'flex', 247 | flexDirection: 'row', 248 | width: '100%', 249 | borderRadius: padding.m, 250 | padding: padding.m, 251 | marginVertical: padding.s, 252 | backgroundColor: colors.bgInteract, 253 | }, 254 | inputText: { 255 | textAlign: 'center', 256 | fontSize: fonts.m, 257 | color: colors.textInteract, 258 | }, 259 | inputField: { 260 | marginVertical: padding.s, 261 | }, 262 | itemWithIconsMenu: { 263 | display: 'flex', 264 | flexDirection: 'row', 265 | alignItems: 'baseline', 266 | justifyContent: 'space-between' 267 | }, 268 | markdown: { 269 | ...theme.inputReadMode, 270 | ...theme.descriptionInput, 271 | text: { 272 | color: colors.text 273 | } 274 | }, 275 | modalContainer: { 276 | ...containerStyles, 277 | ...{ 278 | backgroundColor: colors.bg, 279 | height: '100%' 280 | } 281 | }, 282 | spinnerContainer: { 283 | position: 'absolute', 284 | justifyContent: 'center', 285 | width: '100%', 286 | height: '100%', 287 | zIndex: 10000 288 | }, 289 | spinnerText: { 290 | textAlign: 'center', 291 | fontSize: fonts.xl, 292 | color: '#666666', 293 | marginBottom: 5 294 | }, 295 | stackBar: { 296 | flex: 1, 297 | flexDirection: 'row', 298 | borderBottomColor: colors.border, 299 | borderBottomWidth: 1, 300 | backgroundColor: colors.bgDefault, 301 | width: '100%', 302 | }, 303 | stackBarScrollInner: { 304 | paddingRight: padding.m, 305 | paddingLeft: padding.m, 306 | minWidth: '100%', 307 | }, 308 | stackTab: { 309 | flexGrow: 1, 310 | justifyContent: 'center', 311 | }, 312 | stackTabDraggedOver: { 313 | backgroundColor: colors.bgInteract, 314 | }, 315 | stackTabText: { 316 | textAlign: 'center', 317 | textTransform: 'uppercase', 318 | color: colors.text, 319 | padding: padding.m, 320 | }, 321 | stackTabTextSelected: { 322 | fontWeight: 'bold' 323 | }, 324 | stackTabTextNormal: { 325 | fontWeight: 'normal' 326 | }, 327 | textWarning: { 328 | color: colors.text, 329 | textAlign: 'center', 330 | fontSize: fonts.l, 331 | }, 332 | textCheckbox: { 333 | color: colors.text, 334 | marginLeft: 5, 335 | }, 336 | title: { 337 | color: colors.text, 338 | fontSize: fonts.xl, 339 | fontWeight: '600', 340 | marginTop: padding.m, 341 | marginBottom: padding.s, 342 | }, 343 | } 344 | } 345 | 346 | const createStyles = (theme = 'light', overrides = {}) => { 347 | const styles = baseStyles(theme) 348 | return StyleSheet.create({...styles, ...overrides}) 349 | } 350 | 351 | export default createStyles 352 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | import Toast from 'react-native-toast-message' 2 | import {i18n} from './i18n/i18n.js' 3 | import * as FileSystem from 'expo-file-system' 4 | import * as Localization from 'expo-localization' 5 | import axios from 'axios' 6 | import { adapty } from 'react-native-adapty' // in-app purchases 7 | import {createPaywallView} from '@adapty/react-native-ui' // in-app purchases 8 | 9 | export async function fetchAttachments(boardId, stackId, cardId, server, token) { 10 | console.log('fetching attachments from server') 11 | return await axios.get(server.value + `/index.php/apps/deck/api/v1.1/boards/${boardId}/stacks/${stackId}/cards/${cardId}/attachments`, { 12 | timeout: 8000, 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | 'Authorization': token 16 | } 17 | }).then((resp) => { 18 | if (resp.status !== 200) { 19 | Toast.show({ 20 | type: 'error', 21 | text1: i18n.t('error'), 22 | text2: resp, 23 | }) 24 | console.log('Error', resp) 25 | } else { 26 | console.log('attachments fetched from server') 27 | const attachments = resp.data.map(attachment => { 28 | return { 29 | 'id': attachment.id, 30 | 'author': attachment.createdBy, 31 | 'creationDate': new Date(attachment.createdAt * 1000).toLocaleDateString(Localization.locale, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }), 32 | 'name': attachment.data 33 | } 34 | }) 35 | return attachments 36 | } 37 | }).catch((error) => { 38 | Toast.show({ 39 | type: 'error', 40 | text1: i18n.t('error'), 41 | text2: error.message, 42 | }) 43 | console.log(error) 44 | }) 45 | } 46 | 47 | export async function getAttachmentURI(attachment, boardId, stackId, cardId, server, token) { 48 | console.log(`Getting attachment URI for attachement ${attachment.name}`) 49 | try { 50 | // iOS does not like spaces in file names. 51 | const filename = attachment.name.replaceAll(/\s/g,'_') 52 | // Downloads file if not already done 53 | const fileInfo = await FileSystem.getInfoAsync(FileSystem.cacheDirectory + filename) 54 | let uri 55 | if (!fileInfo.exists) { 56 | console.log('Downloading attachment') 57 | const resp = await FileSystem.downloadAsync( 58 | server.value + `/index.php/apps/deck/api/v1.1/boards/${boardId}/stacks/${stackId}/cards/${cardId}/attachments/file/${attachment.id}`, 59 | FileSystem.cacheDirectory + filename, 60 | { 61 | headers: { 62 | 'Authorization': token 63 | }, 64 | }, 65 | ) 66 | uri = await FileSystem.getContentUriAsync(resp.uri) 67 | 68 | } else { 69 | console.log('File already in cache') 70 | uri = await FileSystem.getContentUriAsync(fileInfo.uri) 71 | } 72 | console.log(`attachment URI is ${uri}`) 73 | return uri 74 | } catch(error) { 75 | Toast.show({ 76 | type: 'error', 77 | text1: i18n.t('error'), 78 | text2: error.message, 79 | }) 80 | console.log(error) 81 | return null 82 | } 83 | 84 | } 85 | 86 | // Gets user details from the server 87 | export async function getUserDetails(userId, server, token) { 88 | return axios.get(server.value + `/ocs/v1.php/cloud/users/${userId}`, 89 | { 90 | headers: { 91 | 'Authorization': token, 92 | 'OCS-APIRequest': true, 93 | }, 94 | } 95 | ).then( resp => { 96 | return resp.data.ocs.data 97 | }) 98 | } 99 | 100 | // Tells if a user has edit rights on a board 101 | export function canUserEditBoard(user, board) { 102 | 103 | let canUserEditBoard = true 104 | 105 | if (user === board.owner.uid) { 106 | // User is owner of the board 107 | console.log('User is owner of the board') 108 | canUserEditBoard = true 109 | } else { 110 | // If user is listed in the board's acl explicitly, then return his edit permissions 111 | const userPermissions = board.acl.find(acl => acl.participant.uid==user)?.permissionEdit 112 | if (userPermissions !== undefined) { 113 | console.log('User is listed in board ACL') 114 | canUserEditBoard = userPermissions 115 | } else { 116 | // If user is member of several groups listed in the board's acl, every groups must have edit rights 117 | board.acl.every( acl => { 118 | if (user.groups.includes(acl.participant.uid)) { 119 | console.log('User is listed in board ACL') 120 | canUserEditBoard = acl.permissionEdit 121 | } 122 | }) 123 | } 124 | } 125 | 126 | console.log(canUserEditBoard ? 'User can edit board' : 'User cannot edit board') 127 | 128 | return canUserEditBoard 129 | 130 | } 131 | 132 | // Tells if a user is subscribed to the paying version of the app 133 | export async function isUserSubscribed() { 134 | console.log('Getting user subscription status') 135 | try { 136 | const profile = await adapty.getProfile() 137 | if (profile.accessLevels["No Ads"]?.isActive) { 138 | console.log('User is subscribed') 139 | return true 140 | } else { 141 | console.log('User is not subscribed') 142 | return false 143 | } 144 | } catch (error) { 145 | console.error(error) 146 | return true 147 | } 148 | } 149 | 150 | // Shows adapty paywall 151 | export async function showPaywall(hard = false) { 152 | try { 153 | const paywallId = hard ? 'NoAdsForcedPlacement' : 'NoAdsDefaultPlacement' 154 | console.log('Showing adapty paywall', paywallId) 155 | const paywall = await adapty.getPaywall(paywallId, 'en') 156 | const view = await createPaywallView(paywall) 157 | view.registerEventHandlers() 158 | await view.present() 159 | } catch (error) { 160 | console.error(error) 161 | } 162 | } -------------------------------------------------------------------------------- /views/AllBoards.js: -------------------------------------------------------------------------------- 1 | //=============================================================================================================================================== 2 | // 3 | // AllBoards: A view displaying the user's boards 4 | // 5 | // This file is part of "Nextcloud Deck". 6 | // 7 | // "Nextcloud Deck" is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 8 | // as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 9 | // 10 | // "Nextcloud Deck" is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warrant 11 | // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License along with "Nextcloud Deck". If not, see . 14 | // 15 | //=============================================================================================================================================== 16 | 17 | import React from 'react'; 18 | import { connect } from 'react-redux'; 19 | import { bindActionCreators } from 'redux' 20 | import { addBoard, deleteAllBoards } from '../store/boardSlice'; 21 | import { setServer } from '../store/serverSlice'; 22 | import { setToken } from '../store/tokenSlice'; 23 | import { Image, RefreshControl, ScrollView, View, TextInput } from 'react-native'; 24 | import { DraxProvider } from 'react-native-drax'; 25 | import { initialWindowMetrics } from 'react-native-safe-area-context'; 26 | import Toast from 'react-native-toast-message'; 27 | import { FloatingAction } from "react-native-floating-action"; 28 | import {i18n} from '../i18n/i18n.js'; 29 | import axios from 'axios'; 30 | import AppMenu from '../components/AppMenu'; 31 | import Board from '../components/Board'; 32 | import {decode as atob} from 'base-64'; 33 | 34 | class AllBoards extends React.Component { 35 | 36 | constructor(props) { 37 | super(props) 38 | this.state = { 39 | creatingBoard: false, 40 | lastRefresh: new Date(0), 41 | refreshing: false, 42 | newBoardName: '', 43 | } 44 | this.loadBoards = this.loadBoards.bind(this); 45 | this.insets = initialWindowMetrics?.insets ?? { 46 | left: 0, 47 | right: 0, 48 | bottom: 0, 49 | top: 0, 50 | } 51 | this.user = atob(this.props.token.value.substring(6)).split(':')[0] 52 | } 53 | 54 | async componentDidMount() { 55 | 56 | this.props.navigation.setOptions({ 57 | headerTitle: i18n.t('allBoards'), 58 | headerRight: () => () 59 | }, [this.props.navigation, this.props.setServer, this.props.setToken]) 60 | 61 | await this.loadBoards() 62 | 63 | // Navigate to last viewed board+stack on initial app load 64 | if (this.props.route.params.navigation.boardId !== null) { 65 | console.log('Initial app loading, navigating to last viewed board+stack') 66 | this.props.navigation.navigate('BoardDetails', { 67 | boardId: this.props.route.params.navigation.boardId, 68 | stackId: this.props.route.params.navigation.stackId, 69 | }) 70 | } 71 | 72 | } 73 | 74 | render() { 75 | const icon = 83 | 84 | return ( 85 | 86 | 92 | } > 93 | {typeof Object.values(this.props.boards.value) !== 'undefined' && Object.values(this.props.boards.value).map((board) => 94 | 98 | )} 99 | 100 | {!this.state.creatingBoard && 101 | { {this.setState({creatingBoard: true})} }} 114 | /> 115 | } 116 | {this.state.creatingBoard && 117 | 118 | 119 | { 124 | this.setState({creatingBoard: false}) 125 | this.setState({ newBoardName: '' }) 126 | }} 127 | onChangeText={newBoardName => { 128 | this.setState({ newBoardName }) 129 | }} 130 | onSubmitEditing={() => this.createBoard()} 131 | placeholder={i18n.t('newBoardHint')} 132 | returnKeyType='send' /> 133 | 134 | 135 | } 136 | 137 | ) 138 | } 139 | 140 | createBoard() { 141 | this.setState({creatingBoard: false}) 142 | this.setState({ newBoardName: '' }) 143 | axios.post(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards`, 144 | { 145 | title: this.state.newBoardName, 146 | color: (Math.floor(Math.random() * 2 ** 24)).toString(16).padStart(0, 6) 147 | }, 148 | { 149 | timeout: 8000, 150 | headers: { 151 | 'Content-Type': 'application/json', 152 | 'Authorization': this.props.token.value 153 | }, 154 | } 155 | ).then((resp) => { 156 | if (resp.status !== 200) { 157 | Toast.show({ 158 | type: 'error', 159 | text1: i18n.t('error'), 160 | text2: resp, 161 | }) 162 | console.warn('Error', resp) 163 | } else { 164 | console.log('Board created') 165 | this.props.addBoard(resp.data) 166 | } 167 | }).catch((error) => { 168 | Toast.show({ 169 | type: 'error', 170 | text1: i18n.t('error'), 171 | text2: error.message, 172 | }) 173 | this.setState({ newBoardName: '' }) 174 | console.warn(error) 175 | }) 176 | } 177 | 178 | // Gets all user boards 179 | async loadBoards() { 180 | console.log('Retrieving boards from server') 181 | 182 | this.setState({ 183 | refreshing: true 184 | }) 185 | 186 | await axios.get(this.props.server.value + '/index.php/apps/deck/api/v1.0/boards', { 187 | timeout: 8000, 188 | headers: { 189 | 'Content-Type': 'application/json', 190 | 'Authorization': this.props.token.value, 191 | // Doesn't work yet 'If-Modified-Since': this.state.lastRefresh.toUTCString() 192 | } 193 | }) 194 | .then((resp) => { 195 | if (resp.status !== 200) { 196 | Toast.show({ 197 | type: 'error', 198 | text1: i18n.t('error'), 199 | text2: resp, 200 | }) 201 | console.warn('Error', resp) 202 | } else { 203 | console.log('boards retrieved from server') 204 | this.setState({ 205 | lastRefresh: new Date(), 206 | refreshing: false 207 | }) 208 | this.props.deleteAllBoards() 209 | resp.data.forEach(board => { 210 | // Do not display deleted and archived boards 211 | if (!board.archived && !board.deletedAt) { 212 | this.props.addBoard(board) 213 | } 214 | }) 215 | } 216 | }) 217 | .catch((error) => { 218 | Toast.show({ 219 | type: 'error', 220 | text1: i18n.t('error'), 221 | text2: error.message, 222 | }) 223 | this.setState({ 224 | refreshing: false 225 | }) 226 | console.warn('Error while retrieving boards from the server', error) 227 | }) 228 | } 229 | 230 | } 231 | 232 | // Connect to store 233 | const mapStateToProps = state => ({ 234 | boards: state.boards, 235 | server: state.server, 236 | theme: state.theme, 237 | token: state.token, 238 | }) 239 | const mapDispatchToProps = dispatch => ( 240 | bindActionCreators( { 241 | addBoard, 242 | deleteAllBoards, 243 | setServer, 244 | setToken 245 | }, dispatch) 246 | ) 247 | export default connect( 248 | mapStateToProps, 249 | mapDispatchToProps 250 | )(AllBoards) 251 | -------------------------------------------------------------------------------- /views/BoardDetails.js: -------------------------------------------------------------------------------- 1 | //=============================================================================================================================================== 2 | // 3 | // BoardDetails: A component that display a board's cards, grouped by stack 4 | // 5 | // This file is part of "Nextcloud Deck". 6 | // 7 | // "Nextcloud Deck" is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 8 | // as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 9 | // 10 | // "Nextcloud Deck" is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warrant 11 | // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License along with "Nextcloud Deck". If not, see . 14 | // 15 | //=============================================================================================================================================== 16 | 17 | import React from 'react'; 18 | import { connect } from 'react-redux'; 19 | import { bindActionCreators } from 'redux'; 20 | import { addCard, addLabel, addStack, addUser, deleteStack, moveCard, renameStack } from '../store/boardSlice'; 21 | import { setServer } from '../store/serverSlice'; 22 | import { setToken } from '../store/tokenSlice'; 23 | import AppMenu from '../components/AppMenu'; 24 | import Card from '../components/Card'; 25 | import { canUserEditBoard, getUserDetails } from '../utils'; 26 | import { ActionSheetIOS, Image, Pressable, RefreshControl, Text, TextInput, View } from 'react-native'; 27 | import { DraxProvider, DraxScrollView, DraxView } from 'react-native-drax'; 28 | import axios from 'axios'; 29 | import AsyncStorage from '@react-native-async-storage/async-storage'; 30 | import { initialWindowMetrics } from 'react-native-safe-area-context'; 31 | import { HeaderBackButton } from '@react-navigation/elements'; 32 | import { FloatingAction } from "react-native-floating-action"; 33 | import Toast from 'react-native-toast-message'; 34 | import { i18n } from '../i18n/i18n.js'; 35 | import {decode as atob} from 'base-64'; 36 | 37 | class BoardDetails extends React.Component { 38 | 39 | constructor(props) { 40 | super(props) 41 | this.state = { 42 | addingCard: false, 43 | addingStack: false, 44 | cardPressed: -1, // array of cards pressed 45 | index: null, // the index of the stack currently shown 46 | newCardName: '', 47 | newStackName: undefined, 48 | refreshing: false, 49 | stackToRename: false, 50 | user: { 51 | id: atob(this.props.token.value.substring(6)).split(':')[0] 52 | } 53 | } 54 | this.createCard = this.createCard.bind(this) 55 | this.createStack = this.createStack.bind(this) 56 | this.deleteStack = this.deleteStack.bind(this) 57 | this.loadBoard = this.loadBoard.bind(this) 58 | this.moveCard = this.moveCard.bind(this) 59 | this.insets = initialWindowMetrics?.insets ?? { 60 | left: 0, 61 | right: 0, 62 | bottom: 0, 63 | top: 0, 64 | } 65 | 66 | } 67 | 68 | async componentDidMount() { 69 | 70 | // Setup page's header bar 71 | const title = this.props.boards.value[this.props.route.params.boardId].title 72 | this.props.navigation.setOptions({ 73 | headerTitle: title.length > 25 ? title.slice(0,24) + '...' : title, 74 | headerRight: () => (), 75 | headerLeft: () => ( 76 | { 80 | AsyncStorage.removeItem('navigation') 81 | this.props.navigation.navigate('AllBoards') 82 | }} 83 | /> 84 | ) 85 | }) 86 | 87 | // Gets board details if not yet done 88 | if (this.props.boards.value[this.props.route.params.boardId].stacks.length === 0) { 89 | await this.loadBoard() 90 | } else { 91 | // Navigates to stack with order === 0 92 | this.setState({ 93 | index: this.props.boards.value[this.props.route.params.boardId].stacks[0].id, 94 | }) 95 | } 96 | 97 | // Gets user details 98 | getUserDetails(this.state.user.id, this.props.server, this.props.token.value).then( details => { 99 | const canEditBoard = canUserEditBoard(details,this.props.boards.value[this.props.route.params.boardId]) 100 | this.setState({user: {...details, canEditBoard} }) 101 | }) 102 | } 103 | 104 | render() { 105 | 106 | const icon = 114 | 115 | const stacks = this.props.boards.value[this.props.route.params.boardId]?.stacks 116 | if (stacks === undefined) { 117 | return ( 118 | 119 | 120 | ) 121 | } else if (stacks.length === 0 && !this.state.refreshing) { 122 | // Board has no stack 123 | return ( 124 | 125 | 126 | 127 | {i18n.t('noStack')} 128 | 129 | 130 | 131 | { 136 | this.setState({ newStackName }) 137 | }} 138 | onSubmitEditing={() => this.createStack(this.state.newStackName)} 139 | placeholder={i18n.t('newStackHint')} 140 | returnKeyType='send' 141 | /> 142 | 143 | 144 | ) 145 | } else { 146 | const currentStack = stacks.find(oneStack => oneStack.id === this.state.index) 147 | return ( 148 | 149 | 150 | 158 | } 159 | stickyHeaderIndices={[0]} 160 | > 161 | {/* This view is needed as an extra wrapper, 162 | ScrollView can use to make the containing view sticky, 163 | without changing styles on the containing view */} 164 | 165 | 170 | {stacks.map(stack => ( 171 | { 176 | // Don't try to move card when the drop stack is the same 177 | if (stack.id !== payload.stackId) { 178 | console.log(`moving card ${payload.id}`) 179 | this.moveCard(payload.id, stack.id) 180 | } 181 | }} 182 | > 183 | { 186 | // Switches to selected stack and remember navigation 187 | console.log(`Navigating to stack ${stack.id}`) 188 | this.setState({ 189 | index: stack.id, 190 | }) 191 | AsyncStorage.setItem('navigation', JSON.stringify({ 192 | boardId: this.props.route.params.boardId, 193 | stackId: stack.id, 194 | })) 195 | }} 196 | onLongPress={() => { 197 | if (this.state.user.canEditBoard) { 198 | // Context menu 199 | ActionSheetIOS.showActionSheetWithOptions( 200 | { 201 | options: [i18n.t("cancel"), i18n.t("renameStack"), i18n.t("addStack"), i18n.t("deleteStack")], 202 | destructiveButtonIndex: 3, 203 | cancelButtonIndex: 0, 204 | }, 205 | buttonIndex => { 206 | if (buttonIndex === 0) { 207 | // cancel action 208 | } else if (buttonIndex === 1) { 209 | this.setState({stackToRename: stack}) 210 | } else if (buttonIndex === 2) { 211 | this.setState({addingStack: true}) 212 | } else if (buttonIndex === 3) { 213 | this.deleteStack(stack.id) 214 | } 215 | } 216 | ) 217 | } 218 | }} 219 | > 220 | 221 | {stack.title} 222 | 223 | 224 | 225 | ))} 226 | {/* 227 | 228 | { 233 | menu.current.show(); 234 | }} 235 | > 236 | 237 | 238 | } 239 | > 240 | { 242 | }} 243 | > 244 | {i18n.t('manageBoardLabels')} 245 | 246 | { 248 | }} 249 | > 250 | {i18n.t('manageBoardMembers')} 251 | 252 | { 254 | }} 255 | > 256 | {i18n.t('search')} 257 | 258 | 259 | 260 | */} 261 | 262 | 263 | {currentStack?.cards && 264 | 265 | {Object.values(currentStack.cards).sort((a,b) => a.order - b.order).map(card => ( 266 | 273 | ))} 274 | 275 | } 276 | 277 | 278 | {(!(this.state.addingStack || this.state.addingCard || this.state.stackToRename || !this.state.user.canEditBoard)) && 279 | { this.setState({addingCard: true}) }} 292 | /> 293 | } 294 | {this.state.addingCard && 295 | 296 | 297 | { 302 | this.setState({addingCard: false}) 303 | this.setState({ newCardName: '' }) 304 | }} 305 | onChangeText={newCardName => { 306 | this.setState({ newCardName }) 307 | }} 308 | onSubmitEditing={() => this.createCard(this.state.newCardName)} 309 | placeholder={i18n.t('newCardHint')} 310 | returnKeyType='send' 311 | /> 312 | 313 | 314 | } 315 | {(this.state.addingStack || this.state.stackToRename) && 316 | 317 | 318 | { 324 | this.setState({addingStack: false}) 325 | this.setState({stackToRename: false}) 326 | this.setState({ newStackName: undefined }) 327 | }} 328 | onChangeText={newStackName => { 329 | this.setState({ newStackName }) 330 | }} 331 | onSubmitEditing={() => { 332 | if (this.state.addingStack) { 333 | this.createStack(this.state.newStackName) 334 | } else { 335 | this.renameStack(this.state.index, this.state.newStackName) 336 | } 337 | }} 338 | placeholder={this.state.stackToRename ? false : i18n.t('newStackHint')} 339 | returnKeyType='send' 340 | /> 341 | 342 | 343 | } 344 | 345 | ) 346 | } 347 | } 348 | 349 | createCard(cardName) { 350 | console.log('Creating card', cardName) 351 | axios.post(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}/stacks/${this.state.index}/cards`, 352 | { 353 | description: '', 354 | duedate: null, 355 | title: cardName, 356 | }, 357 | { 358 | timeout: 8000, 359 | headers: { 360 | 'Content-Type': 'application/json', 361 | 'Authorization': this.props.token.value 362 | }, 363 | }) 364 | .then((resp) => { 365 | if (resp.status !== 200) { 366 | Toast.show({ 367 | type: 'error', 368 | text1: i18n.t('error'), 369 | text2: resp, 370 | }) 371 | console.log('Error', resp) 372 | } else { 373 | console.log('Card created') 374 | // Saves card to stack in store 375 | this.props.addCard({ 376 | boardId: this.props.route.params.boardId, 377 | stackId: this.state.index, 378 | card: {...resp.data, labels: []}, 379 | }) 380 | // Reset newCardName and hide newCardName button 381 | this.setState({addingCard: false}) 382 | this.setState({ newCardName: '' }) 383 | 384 | } 385 | }) 386 | .catch((error) => { 387 | Toast.show({ 388 | type: 'error', 389 | text1: i18n.t('error'), 390 | text2: error.message, 391 | }) 392 | console.log(error) 393 | }) 394 | } 395 | 396 | renameStack(stackId, stackName) { 397 | console.log(`Renaming stack ${stackId} to ${stackName}`) 398 | const stacks = this.props.boards.value[this.props.route.params.boardId].stacks 399 | const currentStack = stacks.find(oneStack => oneStack.id === this.state.index) 400 | axios.put(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}/stacks/${stackId}`, 401 | { 402 | title: stackName, 403 | order: currentStack.order 404 | }, 405 | { 406 | timeout: 8000, 407 | headers: { 408 | 'Content-Type': 'application/json', 409 | 'Authorization': this.props.token.value 410 | }, 411 | }) 412 | .then((resp) => { 413 | if (resp.status !== 200) { 414 | Toast.show({ 415 | type: 'error', 416 | text1: i18n.t('error'), 417 | text2: resp, 418 | }) 419 | console.log('Error', resp) 420 | } else { 421 | console.log('Stack renamed') 422 | // Rename stack in store 423 | this.props.renameStack({ 424 | boardId: this.props.route.params.boardId, 425 | stackId: this.state.index, 426 | stackTitle: stackName 427 | }) 428 | // Reset newStackName and hide newStackName button 429 | this.setState({renamingStack: false}) 430 | this.setState({ newStackName: '' }) 431 | } 432 | }) 433 | .catch((error) => { 434 | Toast.show({ 435 | type: 'error', 436 | text1: i18n.t('error'), 437 | text2: error.message, 438 | }) 439 | console.log(error) 440 | }) 441 | } 442 | 443 | createStack(stackName) { 444 | console.log('Creating stack', stackName) 445 | // Finds stack with highest order (should probably be a selector but I haven't figure out yet how to do it) 446 | var lastStack = { order: 0 } 447 | this.props.boards.value[this.props.route.params.boardId].stacks.forEach(stack => { 448 | if (stack.order >= lastStack.order) { 449 | lastStack = stack 450 | } 451 | }) 452 | // Creates stack 453 | axios.post(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}/stacks`, 454 | { 455 | title: stackName, 456 | order: lastStack.order + 1 // Puts stack after latest one 457 | }, 458 | { 459 | timeout: 8000, 460 | headers: { 461 | 'Content-Type': 'application/json', 462 | 'Authorization': this.props.token.value 463 | }, 464 | }) 465 | .then((resp) => { 466 | if (resp.status !== 200) { 467 | Toast.show({ 468 | type: 'error', 469 | text1: i18n.t('error'), 470 | text2: resp, 471 | }) 472 | console.log('Error', resp) 473 | } else { 474 | console.log('Stack created') 475 | // Add stack to board in store 476 | this.props.addStack({ 477 | boardId: this.props.route.params.boardId, 478 | stack: resp.data 479 | }) 480 | // Navigate to stack when it's the first one created 481 | if (this.state.index === 0 ) { 482 | this.setState({ 483 | index: this.props.boards.value[this.props.route.params.boardId].stacks[0].id, 484 | }) 485 | } 486 | // Reset newStackName and hide newStackName button 487 | this.setState({addingStack: false}) 488 | this.setState({ newStackName: '' }) 489 | } 490 | }) 491 | .catch((error) => { 492 | Toast.show({ 493 | type: 'error', 494 | text1: i18n.t('error'), 495 | text2: error.message, 496 | }) 497 | console.log(error) 498 | }) 499 | } 500 | 501 | // Loads the detailed information of the board 502 | async loadBoard() { 503 | 504 | // Shows loading spinner 505 | this.setState({ 506 | refreshing: true 507 | }) 508 | 509 | // Retrieves board details (eg:labels) 510 | // TODO: Merge both axios requests 511 | console.log('Retrieving board details from server') 512 | await axios.get(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}`, { 513 | timeout: 8000, 514 | headers: { 515 | 'Content-Type': 'application/json', 516 | 'Authorization': this.props.token.value 517 | } 518 | }).then((resp) => { 519 | if (resp.status !== 200) { 520 | Toast.show({ 521 | type: 'error', 522 | text1: i18n.t('error'), 523 | text2: resp, 524 | }) 525 | console.log('Error', resp) 526 | } else { 527 | console.log('board details retrieved from server') 528 | // Add labels to board in store 529 | console.log('Adding labels info to the board in store') 530 | resp.data.labels.forEach(label => { 531 | this.props.addLabel({ 532 | boardId: this.props.route.params.boardId, 533 | label 534 | }) 535 | }) 536 | // Add users to board in store 537 | console.log('Adding users info to the board in store') 538 | resp.data.users.forEach(user => { 539 | this.props.addUser({ 540 | boardId: this.props.route.params.boardId, 541 | user 542 | }) 543 | }) 544 | } 545 | }).catch((error) => { 546 | Toast.show({ 547 | type: 'error', 548 | text1: i18n.t('error'), 549 | text2: error.message, 550 | }) 551 | console.log(error) 552 | }) 553 | 554 | // Retrieves board stacks 555 | console.log('Retrieving board stacks from server') 556 | await axios.get(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}/stacks`, { 557 | timeout: 8000, 558 | headers: { 559 | 'Content-Type': 'application/json', 560 | 'Authorization': this.props.token.value 561 | } 562 | }).then((resp) => { 563 | if (resp.status !== 200) { 564 | Toast.show({ 565 | type: 'error', 566 | text1: i18n.t('error'), 567 | text2: resp, 568 | }) 569 | console.log('Error', resp) 570 | } else { 571 | console.log('board details retrieved from server') 572 | 573 | // Add stacks to board in store 574 | resp.data.forEach(stack => { 575 | this.props.addStack({ 576 | boardId: this.props.route.params.boardId, 577 | stack 578 | }) 579 | }) 580 | 581 | // Shows last visited stack or stack with order === 0 (assumes server's answer is ordered) 582 | // TODO: handle case where the remembered stackId has been deleted 583 | if (resp.data.length > 0) { 584 | this.setState({ 585 | index: (this.props.route.params.stackId !== null && this.state.index === null) ? parseInt(this.props.route.params.stackId) : this.state.index ?? resp.data[0].id, 586 | }) 587 | } 588 | } 589 | }).catch((error) => { 590 | Toast.show({ 591 | type: 'error', 592 | text1: i18n.t('error'), 593 | text2: error.message, 594 | }) 595 | console.log(error) 596 | }) 597 | 598 | // Hides loading spinner 599 | this.setState({ 600 | refreshing: false 601 | }) 602 | } 603 | 604 | moveCard(cardId, stackId) { 605 | this.props.moveCard({ 606 | boardId: this.props.route.params.boardId, 607 | oldStackId: this.state.index, 608 | newStackId: stackId, 609 | cardId 610 | }) 611 | axios.put(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}/stacks/${this.state.index}/cards/${cardId}/reorder`, 612 | { 613 | order: 0, 614 | stackId, 615 | }, 616 | { 617 | timeout: 8000, 618 | headers: { 619 | 'Content-Type': 'application/json', 620 | 'Authorization': this.props.token.value 621 | } 622 | }) 623 | .then((resp) => { 624 | if (resp.status !== 200) { 625 | Toast.show({ 626 | type: 'error', 627 | text1: i18n.t('error'), 628 | text2: resp, 629 | }) 630 | console.log('Error', resp) 631 | } else { 632 | console.log('card moved') 633 | } 634 | }) 635 | .catch((error) => { 636 | // Reverts change and inform user 637 | console.log(error) 638 | Toast.show({ 639 | type: 'error', 640 | text1: i18n.t('error'), 641 | text2: error.message, 642 | }) 643 | this.props.moveCard({ 644 | boardId: this.props.route.params.boardId, 645 | oldStackId: stackId, 646 | newStackId: this.state.index, 647 | cardId 648 | }) 649 | }) 650 | } 651 | 652 | deleteStack(stackId) { 653 | console.log(`deleting stack ${stackId}`) 654 | axios.delete(this.props.server.value + `/index.php/apps/deck/api/v1.0/boards/${this.props.route.params.boardId}/stacks/${stackId}`, 655 | { 656 | timeout: 8000, 657 | headers: { 658 | 'Content-Type': 'application/json', 659 | 'Authorization': this.props.token.value 660 | }, 661 | }) 662 | .then((resp) => { 663 | if (resp.status !== 200) { 664 | Toast.show({ 665 | type: 'error', 666 | text1: i18n.t('error'), 667 | text2: resp, 668 | }) 669 | console.log('Error', resp) 670 | } else { 671 | console.log('Stack deleted') 672 | this.props.deleteStack({ 673 | boardId: this.props.route.params.boardId, 674 | stackId, 675 | }) 676 | } 677 | }) 678 | .catch((error) => { 679 | Toast.show({ 680 | type: 'error', 681 | text1: i18n.t('error'), 682 | text2: error.message, 683 | }) 684 | console.log(error) 685 | }) 686 | } 687 | 688 | } 689 | 690 | // Connect to store 691 | const mapStateToProps = state => ({ 692 | boards: state.boards, 693 | server: state.server, 694 | theme: state.theme, 695 | token: state.token, 696 | }) 697 | 698 | const mapDispatchToProps = dispatch => ( 699 | bindActionCreators( { 700 | addCard, 701 | addLabel, 702 | addStack, 703 | addUser, 704 | deleteStack, 705 | moveCard, 706 | renameStack, 707 | setServer, 708 | setToken, 709 | }, dispatch) 710 | ) 711 | 712 | export default connect( 713 | mapStateToProps, 714 | mapDispatchToProps, 715 | )(BoardDetails) 716 | -------------------------------------------------------------------------------- /views/CardDetails.js: -------------------------------------------------------------------------------- 1 | //=============================================================================================================================================== 2 | // 3 | // CardDetails: The detailed view of a card, showing all card's information 4 | // 5 | // This file is part of "Nextcloud Deck". 6 | // 7 | // "Nextcloud Deck" is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 8 | // as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 9 | // 10 | // "Nextcloud Deck" is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warrant 11 | // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 | // 13 | // You should have received a copy of the GNU General Public License along with "Nextcloud Deck". If not, see . 14 | // 15 | //=============================================================================================================================================== 16 | 17 | import React, { useState, useEffect } from 'react' 18 | import { useDispatch, useSelector } from 'react-redux' 19 | import { useNavigation, useRoute } from '@react-navigation/native' 20 | import { addCard } from '../store/boardSlice' 21 | import AppMenu from '../components/AppMenu' 22 | import AssigneeList from '../components/AssigneeList' 23 | import AttachmentPanel from '../components/AttachmentPanel' 24 | import CommentPanel from '../components/CommentPanel' 25 | import LabelList from '../components/LabelList' 26 | import Spinner from '../components/Spinner' 27 | import { canUserEditBoard, fetchAttachments, getAttachmentURI, getUserDetails } from '../utils' 28 | import { Alert, Pressable, ScrollView, TextInput, View } from 'react-native' 29 | import { Text } from 'react-native-elements' 30 | import { HeaderBackButton } from '@react-navigation/elements' 31 | import AsyncStorage from '@react-native-async-storage/async-storage' 32 | import BouncyCheckbox from "react-native-bouncy-checkbox" 33 | import DateTimePicker from '@react-native-community/datetimepicker' 34 | import Markdown, { MarkdownIt } from 'react-native-markdown-display' 35 | import axios from 'axios' 36 | import * as Localization from 'expo-localization' 37 | import Toast from 'react-native-toast-message' 38 | import {i18n} from '../i18n/i18n.js' 39 | import {decode as atob} from 'base-64' 40 | import { FloatingAction } from "react-native-floating-action" 41 | import * as MailComposer from 'expo-mail-composer' 42 | 43 | const taskLists = require('markdown-it-task-lists') 44 | const mdParser = MarkdownIt().use(taskLists, {enabled: true}) 45 | 46 | const CardDetails = () => { 47 | 48 | const theme = useSelector(state => state.theme) 49 | const server = useSelector(state => state.server) 50 | const token = useSelector(state => state.token) 51 | const boards = useSelector(state => state.boards) 52 | const dispatch = useDispatch() 53 | 54 | const navigation = useNavigation() 55 | const route = useRoute() 56 | 57 | const [user, setUser] = useState({}) 58 | const [busy, setBusy] = useState(true) 59 | const [card, setCard] = useState({}) 60 | const [cardAssigneesBackup, setcardAssigneesBackup] = useState([]) 61 | const [cardLabelsBackup, setcardLabelsBackup] = useState([]) 62 | const [editMode, setEditMode] = useState(false) 63 | const [showDatePicker, setShowDatePicker] = useState(false) 64 | 65 | // ComponentDidMount 66 | useEffect(() => { 67 | 68 | // Initialises user 69 | const id = atob(token.value.substring(6)).split(':')[0] 70 | getUserDetails(id, server, token.value).then( details => { 71 | let user = details 72 | user.canEditBoard = canUserEditBoard(user,boards.value[route.params.boardId]) 73 | setUser(user) 74 | }) 75 | 76 | // Setup page header 77 | navigation.setOptions({ 78 | headerTitle: i18n.t('cardDetails'), 79 | headerRight: () => (), 80 | headerLeft: () => ( 81 | { 85 | AsyncStorage.removeItem('navigation') 86 | navigation.goBack() 87 | }} 88 | /> 89 | ) 90 | 91 | }) 92 | 93 | // Gets card from store 94 | console.log('Loading card from store') 95 | const cardFromStore = boards.value[route.params.boardId].stacks.find(oneStack => oneStack.id === route.params.stackId).cards[route.params.cardId] 96 | 97 | // Formats duedate properly for DateTimePicker and makes sure the component will show it in edit mode 98 | if (cardFromStore.duedate !== null) { 99 | cardFromStore.duedate = new Date(cardFromStore.duedate) 100 | setShowDatePicker(true) 101 | } 102 | 103 | // Saves card in local state 104 | console.log('Card retrieved from store. Updating frontend') 105 | setCard(cardFromStore) 106 | 107 | // Remembers current card labels and assignees in case we change them 108 | setcardLabelsBackup(cardFromStore.labels) 109 | setcardAssigneesBackup(cardFromStore.assignedUsers) 110 | 111 | setBusy(false) 112 | 113 | }, []) 114 | 115 | // Handler to let the LabelList child update the card's labels 116 | const udpateCardLabelsHandler = (values) => { 117 | const boardLabels = boards.value[route.params.boardId].labels 118 | const labels = boardLabels.filter(label => { 119 | return values.indexOf(label.id) !== -1 120 | }) 121 | setCard({ 122 | ...card, 123 | labels 124 | }) 125 | } 126 | 127 | // Handler to let the AssigneeList child update the card's assigned users 128 | const udpateCardAsigneesHandler = (values) => { 129 | const boardUsers = boards.value[route.params.boardId].users 130 | const assignedUsers = boardUsers.filter(user => { 131 | return values.indexOf(user.uid) !== -1 132 | }).map(user => { return {participant: user} }) 133 | setCard({ 134 | ...card, 135 | assignedUsers 136 | }) 137 | } 138 | 139 | // Saves card and its labels 140 | const saveCard = (cardToBeSaved) => { 141 | setBusy(true) 142 | // Adds new labels 143 | cardToBeSaved.labels.forEach(label => { 144 | if (cardLabelsBackup.every(backupLabel => backupLabel.id !== label.id)) { 145 | console.log('Adding label', label.id) 146 | axios.put(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}/assignLabel`, 147 | {labelId: label.id}, 148 | { 149 | timeout: 8000, 150 | headers: { 151 | 'Content-Type': 'application/json', 152 | 'Authorization': token.value 153 | } 154 | } 155 | ) 156 | } 157 | }) 158 | // Removes labels 159 | cardLabelsBackup.forEach(backupLabel => { 160 | if (cardToBeSaved.labels.every(label => label.id !== backupLabel.id)) { 161 | console.log('Removing label', backupLabel.id) 162 | axios.put(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}/removeLabel`, 163 | {labelId: backupLabel.id}, 164 | { 165 | timeout: 8000, 166 | headers: { 167 | 'Content-Type': 'application/json', 168 | 'Authorization': token.value 169 | } 170 | } 171 | ) 172 | } 173 | }) 174 | // Adds new assignees 175 | cardToBeSaved.assignedUsers.forEach(user => { 176 | if (cardAssigneesBackup?.every(backupUser => backupUser.participant.uid !== user.participant.uid)) { 177 | console.log('Adding assignee', user.participant.uid) 178 | axios.put(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}/assignUser`, 179 | {userId: user.participant.uid}, 180 | { 181 | timeout: 8000, 182 | headers: { 183 | 'Content-Type': 'application/json', 184 | 'Authorization': token.value 185 | } 186 | } 187 | ) 188 | } 189 | }) 190 | // Removes labels 191 | cardAssigneesBackup?.forEach(backupUser => { 192 | if (cardToBeSaved.assignedUsers.every(user => user.participant.uid !== backupUser.participant.uid)) { 193 | console.log('Removing assignee', backupUser.participant.uid) 194 | axios.put(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}/unassignUser`, 195 | {userId: backupUser.participant.uid}, 196 | { 197 | timeout: 8000, 198 | headers: { 199 | 'Content-Type': 'application/json', 200 | 'Authorization': token.value 201 | } 202 | } 203 | ) 204 | } 205 | }) 206 | // Saves card 207 | axios.put(server.value + `/index.php/apps/deck/api/v1.0/boards/${route.params.boardId}/stacks/${route.params.stackId}/cards/${route.params.cardId}`, 208 | cardToBeSaved, 209 | { 210 | timeout: 8000, 211 | headers: { 212 | 'Content-Type': 'application/json', 213 | 'Authorization': token.value 214 | }, 215 | }) 216 | .then((resp) => { 217 | if (resp.status !== 200) { 218 | Toast.show({ 219 | type: 'error', 220 | text1: i18n.t('error'), 221 | text2: resp, 222 | }) 223 | console.error('Error', resp) 224 | } else { 225 | console.log('Card saved') 226 | if (cardToBeSaved.duedate !== null) { 227 | cardToBeSaved = {...cardToBeSaved, duedate: cardToBeSaved.duedate.toString()} 228 | } 229 | dispatch(addCard({ 230 | boardId: route.params.boardId, 231 | stackId: route.params.stackId, 232 | card: cardToBeSaved, 233 | })) 234 | } 235 | setEditMode(false) 236 | setBusy(false) 237 | }) 238 | .catch((error) => { 239 | console.error(error) 240 | if (error.message === 'Request failed with status code 403') { 241 | Toast.show({ 242 | type: 'error', 243 | text1: i18n.t('error'), 244 | text2: i18n.t('unauthorizedToEditCard'), 245 | }) 246 | } else { 247 | Toast.show({ 248 | type: 'error', 249 | text1: i18n.t('error'), 250 | text2: error.message, 251 | }) 252 | } 253 | setBusy(false) 254 | }) 255 | } 256 | 257 | const sendEmail = async(attachments) => { 258 | var options = { 259 | subject: card.title, 260 | body: card.description 261 | } 262 | if (attachments.length > 0) { 263 | options = {...options, attachments} 264 | } 265 | 266 | MailComposer.composeAsync(options) 267 | } 268 | 269 | const sendEmailWithAttachment = async () => { 270 | const attachments = await fetchAttachments(route.params.boardId, route.params.stackId, route.params.cardId, server, token.value) 271 | const attachmentURIs = await Promise.all(attachments.map(async attachment => { 272 | return await getAttachmentURI(attachment,route.params.boardId, route.params.stackId, route.params.cardId, server, token.value) 273 | })); 274 | sendEmail(attachmentURIs) 275 | } 276 | 277 | // Function to check/uncheck task list item in the card's description 278 | const updateCardDescriptionTaskListItem = (text) => { 279 | // tries to uncheck first 280 | let regexp = new RegExp("- \\[x\]" + text, "g") 281 | let description = card.description.replace(regexp, "- [ ]" + text) 282 | // if uncheck didn't change a thing then we must check 283 | if (description === card.description) { 284 | regexp = new RegExp("- \\[ \]" + text, "g") 285 | description = card.description.replace(regexp, "- [x]" + text) 286 | } 287 | const newCard = {...card, description} 288 | setCard(newCard) 289 | saveCard(newCard) 290 | } 291 | 292 | return ( 293 | 294 | 297 | { busy && 298 | 299 | } 300 | 301 | 302 | {i18n.t('title')} 303 | 304 | { setCard({...card, title}) }} 308 | placeholder='title' 309 | /> 310 | 311 | { editMode && 312 | 313 | { 317 | setShowDatePicker(isChecked) 318 | // Sets or delete the card's duedate property 319 | const copyOfCard = {...card} 320 | if (!isChecked) { 321 | copyOfCard['duedate'] = null 322 | } else { 323 | copyOfCard['duedate'] = new Date() 324 | } 325 | // Updates card 326 | setCard(copyOfCard) 327 | }} 328 | /> 329 | 330 | {i18n.t('setDueDate')} 331 | 332 | 333 | } 334 | { (showDatePicker || (!editMode && card.duedate !== null)) && 335 | 336 | 337 | {i18n.t('dueDate')} 338 | 339 | { editMode ? 340 | { 346 | setCard({...card, duedate: newDuedate}) 347 | }} 348 | /> 349 | : 350 | 351 | {new Date(card.duedate).toLocaleDateString(Localization.locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} 352 | 353 | } 354 | 355 | } 356 | { (card.labels?.length > 0 || editMode) && 357 | 358 | 359 | {i18n.t('labels')} 360 | 361 | 366 | 367 | } 368 | { (card.assignedUsers?.length > 0 || editMode) && 369 | 370 | 371 | {i18n.t('assignees')} 372 | 373 | 378 | 379 | } 380 | 381 | 382 | {i18n.t('description')} 383 | 384 | { 385 | editMode ? 386 | { 391 | setCard({...card, description}) 392 | }} 393 | placeholder={i18n.t('descriptionOptional')} 394 | /> 395 | : 396 | ( 399 | { 404 | const text = parent[0].children[1].content 405 | updateCardDescriptionTaskListItem(text) 406 | }} 407 | /> 408 | ) 409 | }} 410 | styles={theme.markdown} 411 | mergeStyle={true} 412 | markdownit={mdParser} 413 | > 414 | {card.description} 415 | 416 | } 417 | 418 | 419 | 424 | 429 | { (editMode === false && user.canEditBoard) && 430 | { 449 | if (name === 'edit') { 450 | setEditMode(true) 451 | } else { 452 | Alert.alert( 453 | i18n.t("sendAttachments"), 454 | i18n.t("sendAttachmentsPrompt"), [ 455 | { 456 | text: i18n.t("no"), 457 | onPress: () => {sendEmail([])}, 458 | style: "cancel" 459 | }, 460 | { 461 | text: i18n.t("yes"), 462 | onPress: sendEmailWithAttachment 463 | } 464 | ] 465 | ) 466 | } 467 | }} > 468 | 469 | } 470 | { (editMode && user.canEditBoard) && 471 | { 473 | saveCard(card) 474 | }} > 475 | 476 | {i18n.t('save')} 477 | 478 | 479 | } 480 | 481 | ) 482 | } 483 | 484 | export default CardDetails 485 | -------------------------------------------------------------------------------- /views/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux' 4 | import { setServer } from '../store/serverSlice'; 5 | import AsyncStorage from '@react-native-async-storage/async-storage'; 6 | import { Button, ImageBackground, StyleSheet, Text, TextInput, View } from 'react-native'; 7 | import {i18n} from '../i18n/i18n.js'; 8 | 9 | // Component to specify the URL of the Nextcloud server to connect to 10 | class Home extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | NCserver: undefined 15 | } 16 | this.onSubmit = this.onSubmit.bind(this); 17 | } 18 | 19 | render() { 20 | return ( 21 | 22 | 23 | 24 | 25 | {i18n.t('setUrl')} 26 | 27 | { 30 | this.setState({ 31 | NCserver: server 32 | }) 33 | }} 34 | defaultValue='https://' 35 | autoCapitalize='none' 36 | autoCorrect={false} 37 | keyboardType='url' 38 | textContentType='URL' 39 | /> 40 |