├── .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 | 
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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | onSubmit() {
51 | // Persists NC Server URL and open the login form
52 | console.log('Storing server address in redux store and AsyncStorage', this.state.NCserver)
53 | this.props.setServer(this.state.NCserver)
54 | AsyncStorage.setItem('NCserver', this.state.NCserver)
55 | console.log('Navigating')
56 | this.props.navigation.navigate('Login')
57 | }
58 | }
59 |
60 | // Connect to store
61 | const mapStateToProps = state => ({
62 | server: state.server
63 | })
64 | const mapDispatchToProps = dispatch => (
65 | bindActionCreators( {
66 | setServer,
67 | }, dispatch)
68 | )
69 | export default connect(
70 | mapStateToProps,
71 | mapDispatchToProps
72 | )(Home)
73 |
74 | // Component styles
75 | const styles = StyleSheet.create({
76 | background: {
77 | width: '100%',
78 | height: '100%',
79 | },
80 | container: {
81 | flex: 1,
82 | alignItems: 'center',
83 | justifyContent: 'center',
84 | },
85 | LoginForm: {
86 | position: 'absolute',
87 | top: '10%',
88 | left: '15%',
89 | borderRadius: 8,
90 | borderWidth: 1,
91 | backgroundColor: 'white',
92 | opacity: 0.95,
93 | width: '70%',
94 | height: 180,
95 | padding: 10,
96 | justifyContent: 'space-between',
97 | },
98 | Input: {
99 | borderColor: 'darkslateblue',
100 | backgroundColor: 'white',
101 | opacity: 1,
102 | borderWidth: 1,
103 | borderRadius: 3,
104 | marginTop: 5,
105 | padding: 2,
106 | height: 30,
107 | },
108 | });
--------------------------------------------------------------------------------
/views/Login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import WebView from 'react-native-webview';
3 | import { connect } from 'react-redux';
4 |
5 | // Component to display the chosen NC server's login form
6 | class Login extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | }
10 |
11 | render() {
12 | return (
13 |
19 | )
20 | }
21 | }
22 |
23 | // Connect to store
24 | const mapStateToProps = state => ({
25 | server: state.server
26 | })
27 | export default connect(mapStateToProps)(Login)
28 |
--------------------------------------------------------------------------------
/views/Settings.js:
--------------------------------------------------------------------------------
1 | //===============================================================================================================================================
2 | //
3 | // Settings: The app's Settings view
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 { useDispatch, useSelector } from 'react-redux'
19 | import { Pressable, Text, View } from 'react-native'
20 | import AsyncStorage from '@react-native-async-storage/async-storage'
21 | import SegmentedControl from '@react-native-segmented-control/segmented-control'
22 | import axios from 'axios'
23 | import { deleteAllBoards } from '../store/boardSlice'
24 | import { setServer } from '../store/serverSlice'
25 | import { setToken } from '../store/tokenSlice'
26 | import { setColorScheme } from '../store/colorSchemeSlice'
27 | import { i18n } from '../i18n/i18n.js'
28 | import { isUserSubscribed, showPaywall } from '../utils'
29 | import { getColors } from '../styles/base.js'
30 |
31 | const Settings = () => {
32 |
33 | const theme = useSelector(state => state.theme)
34 | const server = useSelector(state => state.server)
35 | const token = useSelector(state => state.token)
36 | const colorScheme = useSelector(state => state.colorScheme)
37 | const dispatch = useDispatch()
38 | const radioOptions = ['os','light','dark']
39 |
40 | return (
41 |
42 |
43 |
44 | {i18n.t('theme')}
45 |
46 |
47 | {
53 | AsyncStorage.setItem('colorScheme', radioOptions[event.nativeEvent.selectedSegmentIndex] )
54 | dispatch(setColorScheme(radioOptions[event.nativeEvent.selectedSegmentIndex]));
55 | }}
56 | />
57 |
58 |
59 |
60 |
61 | {i18n.t('subscriptions')}
62 |
63 | {isUserSubscribed() === true ?
64 |
65 |
66 | {i18n.t('userSubscribed')}
67 |
68 |
69 | :
70 |
71 |
72 | {i18n.t('useAppWithoutAds')}
73 |
74 | {
76 | showPaywall(false)
77 | }}
78 | >
79 |
80 | {i18n.t('subscribe')}
81 |
82 |
83 |
84 | }
85 |
86 |
87 |
88 | {i18n.t('generalSettings')}
89 |
90 |
91 |
92 |
93 |
94 | {
96 | console.log('Logging out user')
97 | axios.delete(server.value + '/ocs/v2.php/core/apppassword', {
98 | timeout: 8000,
99 | headers: {
100 | 'OCS-APIREQUEST': true,
101 | 'Authorization': token.value
102 | }
103 | }).then(() => {
104 | console.log('User logged out from server')
105 | AsyncStorage.clear()
106 | dispatch(setToken(null))
107 | dispatch(setServer(null))
108 | dispatch(deleteAllBoards())
109 | })
110 | .catch(() => {
111 | console.warn('Error occured while logging user out from server. Trying to clear session here anyway')
112 | AsyncStorage.clear()
113 | dispatch(setToken(null))
114 | dispatch(setServer(null))
115 | dispatch(deleteAllBoards())
116 | })
117 | }}
118 | >
119 |
120 | {i18n.t('logout')}
121 |
122 |
123 |
124 |
125 | )
126 |
127 | }
128 |
129 | export default Settings
--------------------------------------------------------------------------------