NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)' 28
+
29
+          <item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
30
+
31
+          For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
32
+        -->
33
+        <item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
34
+        <item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
35
+    </selector>
36
+
37
+</inset>

+ 5 - 0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3
+    <background android:drawable="@color/iconBackground"/>
4
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
5
+</adaptive-icon>

+ 5 - 0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,5 @@
1
+<?xml version="1.0" encoding="utf-8"?>
2
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3
+    <background android:drawable="@color/iconBackground"/>
4
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
5
+</adaptive-icon>

BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp


BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp


BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp


+ 3 - 0
android/app/src/main/res/values-night/colors.xml

@@ -0,0 +1,3 @@
1
+<resources>
2
+  <color name="splashscreen_background">#000000</color>
3
+</resources>

+ 6 - 0
android/app/src/main/res/values/colors.xml

@@ -0,0 +1,6 @@
1
+<resources>
2
+  <color name="splashscreen_background">#ffffff</color>
3
+  <color name="iconBackground">#ffffff</color>
4
+  <color name="colorPrimary">#023c69</color>
5
+  <color name="colorPrimaryDark">#ffffff</color>
6
+</resources>

+ 6 - 0
android/app/src/main/res/values/strings.xml

@@ -0,0 +1,6 @@
1
+<resources>
2
+  <string name="app_name">SmartFarmLite</string>
3
+  <string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
4
+  <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
5
+  <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
6
+</resources>

+ 14 - 0
android/app/src/main/res/values/styles.xml

@@ -0,0 +1,14 @@
1
+<resources xmlns:tools="http://schemas.android.com/tools">
2
+  <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
3
+    <item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
4
+    <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
5
+    <item name="colorPrimary">@color/colorPrimary</item>
6
+    <item name="android:statusBarColor">#ffffff</item>
7
+  </style>
8
+  <style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
9
+    <item name="windowSplashScreenBackground">@color/splashscreen_background</item>
10
+    <item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
11
+    <item name="postSplashScreenTheme">@style/AppTheme</item>
12
+    <item name="android:windowSplashScreenBehavior">icon_preferred</item>
13
+  </style>
14
+</resources>

+ 24 - 0
android/build.gradle

@@ -0,0 +1,24 @@
1
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
2
+
3
+buildscript {
4
+  repositories {
5
+    google()
6
+    mavenCentral()
7
+  }
8
+  dependencies {
9
+    classpath('com.android.tools.build:gradle')
10
+    classpath('com.facebook.react:react-native-gradle-plugin')
11
+    classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
12
+  }
13
+}
14
+
15
+allprojects {
16
+  repositories {
17
+    google()
18
+    mavenCentral()
19
+    maven { url 'https://www.jitpack.io' }
20
+  }
21
+}
22
+
23
+apply plugin: "expo-root-project"
24
+apply plugin: "com.facebook.react.rootproject"

+ 68 - 0
android/gradle.properties

@@ -0,0 +1,68 @@
1
+# Project-wide Gradle settings.
2
+
3
+# IDE (e.g. Android Studio) users:
4
+# Gradle settings configured through the IDE *will override*
5
+# any settings specified in this file.
6
+
7
+# For more details on how to configure your build environment visit
8
+# http://www.gradle.org/docs/current/userguide/build_environment.html
9
+
10
+# Specifies the JVM arguments used for the daemon process.
11
+# The setting is particularly useful for tweaking memory settings.
12
+# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
13
+org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=2g -Dkotlin.daemon.jvm.options=-Xmx3g
14
+org.gradle.daemon=true
15
+
16
+
17
+# When configured, Gradle will run in incubating parallel mode.
18
+# This option should only be used with decoupled projects. More details, visit
19
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
20
+org.gradle.parallel=true
21
+org.gradle.caching=true
22
+
23
+# AndroidX package structure to make it clearer which packages are bundled with the
24
+# Android operating system, and which are packaged with your app's APK
25
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
26
+android.useAndroidX=true
27
+
28
+# Enable AAPT2 PNG crunching
29
+android.enablePngCrunchInReleaseBuilds=true
30
+
31
+# Use this property to specify which architecture you want to build.
32
+# You can also override it from the CLI using
33
+# ./gradlew <task> -PreactNativeArchitectures=x86_64
34
+reactNativeArchitectures=arm64-v8a
35
+
36
+# Use this property to enable support to the new architecture.
37
+# This will allow you to use TurboModules and the Fabric render in
38
+# your application. You should enable this flag either if you want
39
+# to write custom TurboModules/Fabric components OR use libraries that
40
+# are providing them.
41
+newArchEnabled=true
42
+
43
+# Use this property to enable or disable the Hermes JS engine.
44
+# If set to false, you will be using JSC instead.
45
+hermesEnabled=true
46
+
47
+# Use this property to enable edge-to-edge display support.
48
+# This allows your app to draw behind system bars for an immersive UI.
49
+# Note: Only works with ReactActivity and should not be used with custom Activity.
50
+edgeToEdgeEnabled=true
51
+
52
+# Enable GIF support in React Native images (~200 B increase)
53
+expo.gif.enabled=true
54
+# Enable webp support in React Native images (~85 KB increase)
55
+expo.webp.enabled=true
56
+# Enable animated webp support (~3.4 MB increase)
57
+# Disabled by default because iOS doesn't support animated webp
58
+expo.webp.animated=false
59
+
60
+# Enable network inspector
61
+EX_DEV_CLIENT_NETWORK_INSPECTOR=true
62
+
63
+# Use legacy packaging to compress native libraries in the resulting APK.
64
+expo.useLegacyPackaging=false
65
+
66
+# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
67
+# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
68
+expo.edgeToEdgeEnabled=true

BIN
android/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
1
+distributionBase=GRADLE_USER_HOME
2
+distributionPath=wrapper/dists
3
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
4
+networkTimeout=10000
5
+validateDistributionUrl=true
6
+zipStoreBase=GRADLE_USER_HOME
7
+zipStorePath=wrapper/dists

+ 251 - 0
android/gradlew

@@ -0,0 +1,251 @@
1
+#!/bin/sh
2
+
3
+#
4
+# Copyright © 2015-2021 the original authors.
5
+#
6
+# Licensed under the Apache License, Version 2.0 (the "License");
7
+# you may not use this file except in compliance with the License.
8
+# You may obtain a copy of the License at
9
+#
10
+#      https://www.apache.org/licenses/LICENSE-2.0
11
+#
12
+# Unless required by applicable law or agreed to in writing, software
13
+# distributed under the License is distributed on an "AS IS" BASIS,
14
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+# See the License for the specific language governing permissions and
16
+# limitations under the License.
17
+#
18
+# SPDX-License-Identifier: Apache-2.0
19
+#
20
+
21
+##############################################################################
22
+#
23
+#   Gradle start up script for POSIX generated by Gradle.
24
+#
25
+#   Important for running:
26
+#
27
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28
+#       noncompliant, but you have some other compliant shell such as ksh or
29
+#       bash, then to run this script, type that shell name before the whole
30
+#       command line, like:
31
+#
32
+#           ksh Gradle
33
+#
34
+#       Busybox and similar reduced shells will NOT work, because this script
35
+#       requires all of these POSIX shell features:
36
+#         * functions;
37
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39
+#         * compound commands having a testable exit status, especially «case»;
40
+#         * various built-in commands including «command», «set», and «ulimit».
41
+#
42
+#   Important for patching:
43
+#
44
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
45
+#       by Bash, Ksh, etc; in particular arrays are avoided.
46
+#
47
+#       The "traditional" practice of packing multiple parameters into a
48
+#       space-separated string is a well documented source of bugs and security
49
+#       problems, so this is (mostly) avoided, by progressively accumulating
50
+#       options in "$@", and eventually passing that to Java.
51
+#
52
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54
+#       see the in-line comments for details.
55
+#
56
+#       There are tweaks for specific operating systems such as AIX, CygWin,
57
+#       Darwin, MinGW, and NonStop.
58
+#
59
+#   (3) This script is generated from the Groovy template
60
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61
+#       within the Gradle project.
62
+#
63
+#       You can find Gradle at https://github.com/gradle/gradle/.
64
+#
65
+##############################################################################
66
+
67
+# Attempt to set APP_HOME
68
+
69
+# Resolve links: $0 may be a link
70
+app_path=$0
71
+
72
+# Need this for daisy-chained symlinks.
73
+while
74
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
75
+    [ -h "$app_path" ]
76
+do
77
+    ls=$( ls -ld "$app_path" )
78
+    link=${ls#*' -> '}
79
+    case $link in             #(
80
+      /*)   app_path=$link ;; #(
81
+      *)    app_path=$APP_HOME$link ;;
82
+    esac
83
+done
84
+
85
+# This is normally unused
86
+# shellcheck disable=SC2034
87
+APP_BASE_NAME=${0##*/}
88
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90
+
91
+# Use the maximum available, or set MAX_FD != -1 to use that value.
92
+MAX_FD=maximum
93
+
94
+warn () {
95
+    echo "$*"
96
+} >&2
97
+
98
+die () {
99
+    echo
100
+    echo "$*"
101
+    echo
102
+    exit 1
103
+} >&2
104
+
105
+# OS specific support (must be 'true' or 'false').
106
+cygwin=false
107
+msys=false
108
+darwin=false
109
+nonstop=false
110
+case "$( uname )" in                #(
111
+  CYGWIN* )         cygwin=true  ;; #(
112
+  Darwin* )         darwin=true  ;; #(
113
+  MSYS* | MINGW* )  msys=true    ;; #(
114
+  NONSTOP* )        nonstop=true ;;
115
+esac
116
+
117
+CLASSPATH="\\\"\\\""
118
+
119
+
120
+# Determine the Java command to use to start the JVM.
121
+if [ -n "$JAVA_HOME" ] ; then
122
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123
+        # IBM's JDK on AIX uses strange locations for the executables
124
+        JAVACMD=$JAVA_HOME/jre/sh/java
125
+    else
126
+        JAVACMD=$JAVA_HOME/bin/java
127
+    fi
128
+    if [ ! -x "$JAVACMD" ] ; then
129
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130
+
131
+Please set the JAVA_HOME variable in your environment to match the
132
+location of your Java installation."
133
+    fi
134
+else
135
+    JAVACMD=java
136
+    if ! command -v java >/dev/null 2>&1
137
+    then
138
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139
+
140
+Please set the JAVA_HOME variable in your environment to match the
141
+location of your Java installation."
142
+    fi
143
+fi
144
+
145
+# Increase the maximum file descriptors if we can.
146
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147
+    case $MAX_FD in #(
148
+      max*)
149
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150
+        # shellcheck disable=SC2039,SC3045
151
+        MAX_FD=$( ulimit -H -n ) ||
152
+            warn "Could not query maximum file descriptor limit"
153
+    esac
154
+    case $MAX_FD in  #(
155
+      '' | soft) :;; #(
156
+      *)
157
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158
+        # shellcheck disable=SC2039,SC3045
159
+        ulimit -n "$MAX_FD" ||
160
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
161
+    esac
162
+fi
163
+
164
+# Collect all arguments for the java command, stacking in reverse order:
165
+#   * args from the command line
166
+#   * the main class name
167
+#   * -classpath
168
+#   * -D...appname settings
169
+#   * --module-path (only if needed)
170
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171
+
172
+# For Cygwin or MSYS, switch paths to Windows format before running java
173
+if "$cygwin" || "$msys" ; then
174
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176
+
177
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
178
+
179
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
180
+    for arg do
181
+        if
182
+            case $arg in                                #(
183
+              -*)   false ;;                            # don't mess with options #(
184
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
185
+                    [ -e "$t" ] ;;                      #(
186
+              *)    false ;;
187
+            esac
188
+        then
189
+            arg=$( cygpath --path --ignore --mixed "$arg" )
190
+        fi
191
+        # Roll the args list around exactly as many times as the number of
192
+        # args, so each arg winds up back in the position where it started, but
193
+        # possibly modified.
194
+        #
195
+        # NB: a `for` loop captures its iteration list before it begins, so
196
+        # changing the positional parameters here affects neither the number of
197
+        # iterations, nor the values presented in `arg`.
198
+        shift                   # remove old arg
199
+        set -- "$@" "$arg"      # push replacement arg
200
+    done
201
+fi
202
+
203
+
204
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206
+
207
+# Collect all arguments for the java command:
208
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209
+#     and any embedded shellness will be escaped.
210
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211
+#     treated as '${Hostname}' itself on the command line.
212
+
213
+set -- \
214
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
215
+        -classpath "$CLASSPATH" \
216
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217
+        "$@"
218
+
219
+# Stop when "xargs" is not available.
220
+if ! command -v xargs >/dev/null 2>&1
221
+then
222
+    die "xargs is not available"
223
+fi
224
+
225
+# Use "xargs" to parse quoted args.
226
+#
227
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228
+#
229
+# In Bash we could simply go:
230
+#
231
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232
+#   set -- "${ARGS[@]}" "$@"
233
+#
234
+# but POSIX shell has neither arrays nor command substitution, so instead we
235
+# post-process each arg (as a line of input to sed) to backslash-escape any
236
+# character that might be a shell metacharacter, then use eval to reverse
237
+# that process (while maintaining the separation between arguments), and wrap
238
+# the whole thing up as a single "set" statement.
239
+#
240
+# This will of course break if any of these variables contains a newline or
241
+# an unmatched quote.
242
+#
243
+
244
+eval "set -- $(
245
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246
+        xargs -n1 |
247
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248
+        tr '\n' ' '
249
+    )" '"$@"'
250
+
251
+exec "$JAVACMD" "$@"

+ 94 - 0
android/gradlew.bat

@@ -0,0 +1,94 @@
1
+@rem
2
+@rem Copyright 2015 the original author or authors.
3
+@rem
4
+@rem Licensed under the Apache License, Version 2.0 (the "License");
5
+@rem you may not use this file except in compliance with the License.
6
+@rem You may obtain a copy of the License at
7
+@rem
8
+@rem      https://www.apache.org/licenses/LICENSE-2.0
9
+@rem
10
+@rem Unless required by applicable law or agreed to in writing, software
11
+@rem distributed under the License is distributed on an "AS IS" BASIS,
12
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+@rem See the License for the specific language governing permissions and
14
+@rem limitations under the License.
15
+@rem
16
+@rem SPDX-License-Identifier: Apache-2.0
17
+@rem
18
+
19
+@if "%DEBUG%"=="" @echo off
20
+@rem ##########################################################################
21
+@rem
22
+@rem  Gradle startup script for Windows
23
+@rem
24
+@rem ##########################################################################
25
+
26
+@rem Set local scope for the variables with windows NT shell
27
+if "%OS%"=="Windows_NT" setlocal
28
+
29
+set DIRNAME=%~dp0
30
+if "%DIRNAME%"=="" set DIRNAME=.
31
+@rem This is normally unused
32
+set APP_BASE_NAME=%~n0
33
+set APP_HOME=%DIRNAME%
34
+
35
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37
+
38
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40
+
41
+@rem Find java.exe
42
+if defined JAVA_HOME goto findJavaFromJavaHome
43
+
44
+set JAVA_EXE=java.exe
45
+%JAVA_EXE% -version >NUL 2>&1
46
+if %ERRORLEVEL% equ 0 goto execute
47
+
48
+echo. 1>&2
49
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50
+echo. 1>&2
51
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52
+echo location of your Java installation. 1>&2
53
+
54
+goto fail
55
+
56
+:findJavaFromJavaHome
57
+set JAVA_HOME=%JAVA_HOME:"=%
58
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59
+
60
+if exist "%JAVA_EXE%" goto execute
61
+
62
+echo. 1>&2
63
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64
+echo. 1>&2
65
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66
+echo location of your Java installation. 1>&2
67
+
68
+goto fail
69
+
70
+:execute
71
+@rem Setup the command line
72
+
73
+set CLASSPATH=
74
+
75
+
76
+@rem Execute Gradle
77
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78
+
79
+:end
80
+@rem End local scope for the variables with windows NT shell
81
+if %ERRORLEVEL% equ 0 goto mainEnd
82
+
83
+:fail
84
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85
+rem the _cmd.exe /c_ return code!
86
+set EXIT_CODE=%ERRORLEVEL%
87
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
88
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89
+exit /b %EXIT_CODE%
90
+
91
+:mainEnd
92
+if "%OS%"=="Windows_NT" endlocal
93
+
94
+:omega

+ 39 - 0
android/settings.gradle

@@ -0,0 +1,39 @@
1
+pluginManagement {
2
+  def reactNativeGradlePlugin = new File(
3
+    providers.exec {
4
+      workingDir(rootDir)
5
+      commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
6
+    }.standardOutput.asText.get().trim()
7
+  ).getParentFile().absolutePath
8
+  includeBuild(reactNativeGradlePlugin)
9
+  
10
+  def expoPluginsPath = new File(
11
+    providers.exec {
12
+      workingDir(rootDir)
13
+      commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
14
+    }.standardOutput.asText.get().trim(),
15
+    "../android/expo-gradle-plugin"
16
+  ).absolutePath
17
+  includeBuild(expoPluginsPath)
18
+}
19
+
20
+plugins {
21
+  id("com.facebook.react.settings")
22
+  id("expo-autolinking-settings")
23
+}
24
+
25
+extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
26
+  if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
27
+    ex.autolinkLibrariesFromCommand()
28
+  } else {
29
+    ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
30
+  }
31
+}
32
+expoAutolinking.useExpoModules()
33
+
34
+rootProject.name = 'SmartFarmLite'
35
+
36
+expoAutolinking.useExpoVersionCatalog()
37
+
38
+include ':app'
39
+includeBuild(expoAutolinking.reactNativeGradlePlugin)

+ 69 - 0
app.json

@@ -0,0 +1,69 @@
1
+{
2
+  "expo": {
3
+    "name": "SmartFarmLite",
4
+    "slug": "sf-lite",
5
+    "version": "1.0.0",
6
+    "orientation": "portrait",
7
+    "icon": "./assets/images/icon.png",
8
+    "scheme": "sflite",
9
+    "userInterfaceStyle": "automatic",
10
+    "newArchEnabled": true,
11
+    "ios": {
12
+      "supportsTablet": true,
13
+      "bundleIdentifier": "net.simplico.sflite26",
14
+      "infoPlist": {
15
+        "NSCameraUsageDescription": "Allow SmartFarming Lite to use the camera for task photos.",
16
+        "NSPhotoLibraryUsageDescription": "Allow SmartFarming Lite to access your photos."
17
+      }
18
+    },
19
+    "android": {
20
+      "adaptiveIcon": {
21
+        "foregroundImage": "./assets/images/adaptive-icon.png"
22
+      },
23
+      "edgeToEdgeEnabled": true,
24
+      "predictiveBackGestureEnabled": false,
25
+      "package": "net.simplico.sflite26",
26
+      "permissions": [
27
+        "android.permission.RECORD_AUDIO"
28
+      ]
29
+    },
30
+    "web": {
31
+      "output": "static",
32
+      "favicon": "./assets/images/favicon.png"
33
+    },
34
+    "plugins": [
35
+      "expo-router",
36
+      [
37
+        "expo-image-picker",
38
+        {
39
+          "photosPermission": "Allow SmartFarming Lite to access your photos.",
40
+          "cameraPermission": "Allow SmartFarming Lite to use your camera."
41
+        }
42
+      ],
43
+      [
44
+        "expo-splash-screen",
45
+        {
46
+          "image": "./assets/images/splash-icon.png",
47
+          "imageWidth": 200,
48
+          "resizeMode": "contain",
49
+          "backgroundColor": "#ffffff",
50
+          "dark": {
51
+            "backgroundColor": "#000000"
52
+          }
53
+        }
54
+      ],
55
+      "expo-sqlite",
56
+      "@react-native-community/datetimepicker"
57
+    ],
58
+    "experiments": {
59
+      "typedRoutes": true,
60
+      "reactCompiler": true
61
+    },
62
+    "extra": {
63
+      "router": {},
64
+      "eas": {
65
+        "projectId": "340ba800-49be-404c-a820-d22e7933e8d4"
66
+      }
67
+    }
68
+  }
69
+}

+ 64 - 0
app/(tabs)/_layout.tsx

@@ -0,0 +1,64 @@
1
+import { Tabs } from 'expo-router';
2
+import React from 'react';
3
+
4
+import { HapticTab } from '@/components/haptic-tab';
5
+import { IconSymbol } from '@/components/ui/icon-symbol';
6
+import { Colors } from '@/constants/theme';
7
+import { useColorScheme } from '@/hooks/use-color-scheme';
8
+import { useTranslation } from '@/localization/i18n';
9
+
10
+export default function TabLayout() {
11
+  const colorScheme = useColorScheme();
12
+  const { t } = useTranslation();
13
+
14
+  return (
15
+    <Tabs
16
+      screenOptions={{
17
+        tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
18
+        headerShown: false,
19
+        tabBarButton: HapticTab,
20
+      }}>
21
+      <Tabs.Screen
22
+        name="index"
23
+        options={{
24
+          title: t('tabs.home'),
25
+          tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
26
+        }}
27
+      />
28
+      <Tabs.Screen
29
+        name="logbook"
30
+        options={{
31
+          title: t('tabs.logbook'),
32
+          tabBarIcon: ({ color }) => <IconSymbol size={28} name="book.fill" color={color} />,
33
+        }}
34
+      />
35
+      <Tabs.Screen
36
+        name="explore"
37
+        options={{
38
+          href: null,
39
+        }}
40
+      />
41
+      <Tabs.Screen
42
+        name="blog"
43
+        options={{
44
+          title: t('tabs.blog'),
45
+          tabBarIcon: ({ color }) => <IconSymbol size={28} name="doc.text.image" color={color} />,
46
+        }}
47
+      />
48
+      <Tabs.Screen
49
+        name="onnx"
50
+        options={{
51
+          title: t('tabs.onnx'),
52
+          tabBarIcon: ({ color }) => <IconSymbol size={28} name="bolt.circle.fill" color={color} />,
53
+        }}
54
+      />
55
+      <Tabs.Screen
56
+        name="setup"
57
+        options={{
58
+          title: t('tabs.setup'),
59
+          tabBarIcon: ({ color }) => <IconSymbol size={28} name="leaf.fill" color={color} />,
60
+        }}
61
+      />
62
+    </Tabs>
63
+  );
64
+}

+ 428 - 0
app/(tabs)/blog.tsx

@@ -0,0 +1,428 @@
1
+import { useEffect, useMemo, useState } from 'react';
2
+import { FlatList, Pressable, StyleSheet, View } from 'react-native';
3
+import { Image } from 'expo-image';
4
+import { ThemedText } from '@/components/themed-text';
5
+import { ThemedView } from '@/components/themed-view';
6
+import { ExternalLink } from '@/components/external-link';
7
+import { Colors, Fonts } from '@/constants/theme';
8
+import { useTranslation } from '@/localization/i18n';
9
+import { useColorScheme } from '@/hooks/use-color-scheme';
10
+
11
+const BASE_URL = 'https://simplico.net/wp-json/wp/v2';
12
+const CATEGORY_SLUG = 'blogs';
13
+const PER_PAGE = 10;
14
+
15
+type Post = {
16
+  id: number;
17
+  link: string;
18
+  date: string;
19
+  title: { rendered: string };
20
+  excerpt: { rendered: string };
21
+  _embedded?: {
22
+    'wp:featuredmedia'?: Array<{
23
+      source_url?: string;
24
+    }>;
25
+  };
26
+};
27
+
28
+type Category = {
29
+  id: number;
30
+  name: string;
31
+  link: string;
32
+};
33
+
34
+export default function BlogScreen() {
35
+  const { t } = useTranslation();
36
+  const theme = useColorScheme() ?? 'light';
37
+  const palette = Colors[theme];
38
+  const [posts, setPosts] = useState<Post[]>([]);
39
+  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
40
+  const [categoryId, setCategoryId] = useState<number | null>(null);
41
+  const [categories, setCategories] = useState<Category[]>([]);
42
+  const [page, setPage] = useState(1);
43
+  const [loadingMore, setLoadingMore] = useState(false);
44
+  const [totalPages, setTotalPages] = useState<number | null>(null);
45
+  const [loadMoreDebug, setLoadMoreDebug] = useState('');
46
+  const [langFilter, setLangFilter] = useState<'en' | 'th' | 'ja' | 'zh'>('en');
47
+
48
+  useEffect(() => {
49
+    let isActive = true;
50
+
51
+    async function loadPosts() {
52
+      try {
53
+        const fetchedCategoryId = await fetchCategoryId(CATEGORY_SLUG);
54
+        if (!fetchedCategoryId) {
55
+          setPosts([]);
56
+          setCategories([]);
57
+          setStatus('ready');
58
+          return;
59
+        }
60
+        setCategoryId(fetchedCategoryId);
61
+        const categoryList = await fetchChildCategories(fetchedCategoryId);
62
+        setCategories(categoryList);
63
+        const response = await fetch(
64
+          `${BASE_URL}/posts?per_page=${PER_PAGE}&page=1&_embed=1&cat=9&orderby=modified&order=desc`
65
+        );
66
+        if (!response.ok) {
67
+          throw new Error(`HTTP ${response.status}`);
68
+        }
69
+        const data = (await response.json()) as Post[];
70
+        if (!isActive) return;
71
+        setPosts(data);
72
+        setPage(1);
73
+        setTotalPages(readTotalPages(response));
74
+        setStatus('ready');
75
+      } catch {
76
+        if (isActive) setStatus('error');
77
+      }
78
+    }
79
+
80
+    loadPosts();
81
+    return () => {
82
+      isActive = false;
83
+    };
84
+  }, []);
85
+
86
+  const filteredPosts = useMemo(() => {
87
+    if (langFilter === 'en') {
88
+      return posts.filter((post) => detectLangFromUrl(post.link) === 'en');
89
+    }
90
+    return posts.filter((post) => detectLangFromUrl(post.link) === langFilter);
91
+  }, [langFilter, posts]);
92
+
93
+  const statusText =
94
+    status === 'loading'
95
+      ? t('blog.loading')
96
+      : status === 'error'
97
+        ? t('blog.error')
98
+        : filteredPosts.length === 0
99
+          ? t('blog.empty')
100
+          : '';
101
+
102
+  const canLoadMore = useMemo(() => {
103
+    if (status !== 'ready') return false;
104
+    if (!totalPages) return false;
105
+    return page < totalPages;
106
+  }, [page, status, totalPages]);
107
+
108
+  async function handleLoadMore() {
109
+    if (!categoryId || loadingMore || !canLoadMore) return;
110
+    setLoadingMore(true);
111
+    setLoadMoreDebug('');
112
+    try {
113
+      const nextPage = page + 1;
114
+      setLoadMoreDebug(
115
+        `Requesting page ${nextPage}/${totalPages ?? '?'} (category ${categoryId})`
116
+      );
117
+      const response = await fetch(
118
+        `${BASE_URL}/posts?per_page=${PER_PAGE}&page=${nextPage}&_embed=1&cat=9&orderby=modified&order=desc`
119
+      );
120
+      if (!response.ok) {
121
+        const body = await response.json().catch(() => null);
122
+        if (body?.code === 'rest_post_invalid_page_number') {
123
+          setTotalPages(page);
124
+          setLoadMoreDebug(`No more pages. Server says page ${nextPage} is invalid.`);
125
+          return;
126
+        }
127
+        setLoadMoreDebug(`HTTP ${response.status}`);
128
+        throw new Error(`HTTP ${response.status}`);
129
+      }
130
+      const data = (await response.json()) as Post[];
131
+      setPosts((prev) => mergeUniquePosts(prev, data));
132
+      setPage(nextPage);
133
+      setTotalPages(readTotalPages(response));
134
+      setLoadMoreDebug(
135
+        `Loaded ${data.length} posts. Total pages: ${readTotalPages(response) ?? '?'}`
136
+      );
137
+    } finally {
138
+      setLoadingMore(false);
139
+    }
140
+  }
141
+
142
+  return (
143
+    <FlatList
144
+      data={filteredPosts}
145
+      keyExtractor={(item) => String(item.id)}
146
+      renderItem={({ item }) => (
147
+        <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
148
+          {getFeaturedImage(item) ? (
149
+            <Image
150
+              source={{ uri: getFeaturedImage(item) }}
151
+              style={styles.featuredImage}
152
+              contentFit="cover"
153
+            />
154
+          ) : null}
155
+          <ExternalLink href={item.link}>
156
+            <ThemedText type="subtitle">{stripTags(item.title.rendered)}</ThemedText>
157
+          </ExternalLink>
158
+          <ThemedText style={styles.meta}>{formatDate(item.date)}</ThemedText>
159
+          <ThemedText>{truncateText(stripTags(item.excerpt.rendered), 160)}</ThemedText>
160
+          <View style={styles.readMore}>
161
+            <ExternalLink href={item.link}>
162
+              <ThemedText type="link">Read more</ThemedText>
163
+            </ExternalLink>
164
+          </View>
165
+        </ThemedView>
166
+      )}
167
+      ItemSeparatorComponent={() => <View style={styles.separator} />}
168
+      ListHeaderComponent={
169
+        <View>
170
+          <ThemedView style={styles.hero}>
171
+            <Image
172
+              source={require('@/assets/images/blogpost.jpg')}
173
+              style={styles.heroImage}
174
+              contentFit="cover"
175
+            />
176
+          </ThemedView>
177
+          <ThemedView style={styles.titleContainer}>
178
+            <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
179
+              {t('blog.title')}
180
+            </ThemedText>
181
+          </ThemedView>
182
+
183
+          {statusText ? (
184
+            <ThemedView style={styles.section}>
185
+              <ThemedText>{statusText}</ThemedText>
186
+            </ThemedView>
187
+          ) : null}
188
+
189
+          {categories.length > 0 ? (
190
+            <ThemedView style={styles.section}>
191
+              <ThemedText type="subtitle">Categories</ThemedText>
192
+              <View style={styles.categoryRow}>
193
+                {categories.map((category) => (
194
+                  <ExternalLink key={category.id} href={category.link} asChild>
195
+                    <Pressable
196
+                      style={[
197
+                        styles.categoryBadge,
198
+                        { borderColor: palette.border, backgroundColor: palette.surface },
199
+                      ]}>
200
+                      <ThemedText style={styles.categoryBadgeText}>{category.name}</ThemedText>
201
+                    </Pressable>
202
+                  </ExternalLink>
203
+                ))}
204
+              </View>
205
+            </ThemedView>
206
+          ) : null}
207
+
208
+          <ThemedView style={styles.section}>
209
+            <ThemedText type="subtitle">{t('blog.language')}</ThemedText>
210
+            <View style={styles.segmented}>
211
+              {(['en', 'th', 'ja', 'zh'] as const).map((lang) => (
212
+                <Pressable
213
+                  key={lang}
214
+                  onPress={() => setLangFilter(lang)}
215
+                  style={[
216
+                    styles.segmentItem,
217
+                    { borderColor: palette.border },
218
+                    lang === langFilter
219
+                      ? { borderColor: palette.success, backgroundColor: palette.surface }
220
+                      : undefined,
221
+                  ]}>
222
+                  <ThemedText
223
+                    style={[
224
+                      styles.segmentText,
225
+                      lang === langFilter ? [styles.segmentTextActive, { color: palette.success }] : undefined,
226
+                    ]}>
227
+                    {labelForLang(t, lang)}
228
+                  </ThemedText>
229
+                </Pressable>
230
+              ))}
231
+            </View>
232
+          </ThemedView>
233
+        </View>
234
+      }
235
+      ListFooterComponent={
236
+        loadingMore || loadMoreDebug ? (
237
+          <View style={styles.footer}>
238
+            {loadMoreDebug ? (
239
+              <ThemedText style={styles.debugText}>{loadMoreDebug}</ThemedText>
240
+            ) : null}
241
+            {loadingMore ? <ThemedText>{t('blog.loadingMore')}</ThemedText> : null}
242
+          </View>
243
+        ) : (
244
+          <View style={styles.footer} />
245
+        )
246
+      }
247
+      onEndReached={() => {
248
+        if (canLoadMore) {
249
+          handleLoadMore();
250
+        }
251
+      }}
252
+      onEndReachedThreshold={0.6}
253
+      extraData={langFilter}
254
+    />
255
+  );
256
+}
257
+
258
+function readTotalPages(response: Response) {
259
+  const value = response.headers.get('x-wp-totalpages');
260
+  if (!value) return null;
261
+  const parsed = Number(value);
262
+  return Number.isFinite(parsed) ? parsed : null;
263
+}
264
+
265
+function mergeUniquePosts(existing: Post[], incoming: Post[]) {
266
+  const map = new Map<number, Post>();
267
+  for (const post of existing) map.set(post.id, post);
268
+  for (const post of incoming) map.set(post.id, post);
269
+  return Array.from(map.values());
270
+}
271
+
272
+async function fetchChildCategories(parentId: number) {
273
+  const response = await fetch(`${BASE_URL}/categories?parent=${parentId}&per_page=100`);
274
+  if (!response.ok) return [];
275
+  return (await response.json()) as Category[];
276
+}
277
+
278
+async function fetchCategoryId(slug: string) {
279
+  const response = await fetch(`${BASE_URL}/categories?slug=${encodeURIComponent(slug)}`);
280
+  if (!response.ok) return null;
281
+  const data = (await response.json()) as { id: number }[];
282
+  return data.length > 0 ? data[0].id : null;
283
+}
284
+
285
+function stripTags(html: string) {
286
+  return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
287
+}
288
+
289
+function truncateText(text: string, maxLength: number) {
290
+  if (text.length <= maxLength) return text;
291
+  return `${text.slice(0, maxLength - 1).trim()}…`;
292
+}
293
+
294
+function formatDate(value: string) {
295
+  try {
296
+    return new Date(value).toLocaleDateString();
297
+  } catch {
298
+    return value;
299
+  }
300
+}
301
+
302
+function getFeaturedImage(post: Post) {
303
+  return post._embedded?.['wp:featuredmedia']?.[0]?.source_url ?? '';
304
+}
305
+
306
+function labelForLang(t: (key: string) => string, lang: 'en' | 'th' | 'ja' | 'zh') {
307
+  switch (lang) {
308
+    case 'th':
309
+      return t('blog.lang.th');
310
+    case 'ja':
311
+      return t('blog.lang.ja');
312
+    case 'zh':
313
+      return t('blog.lang.zh');
314
+    default:
315
+      return t('blog.lang.en');
316
+  }
317
+}
318
+
319
+function detectLangFromUrl(url: string) {
320
+  const lower = url.toLowerCase();
321
+  let path = lower;
322
+  try {
323
+    const parsed = new URL(lower);
324
+    path = parsed.pathname;
325
+  } catch {
326
+    path = lower;
327
+  }
328
+  if (/(^|\/)th(\/|$)/.test(path) || /-th(\/|$)/.test(path)) return 'th';
329
+  if (/(^|\/)ja(\/|$)/.test(path) || /-ja(\/|$)/.test(path)) return 'ja';
330
+  if (/(^|\/)zh(\/|$)/.test(path) || /-zh(\/|$)/.test(path)) return 'zh';
331
+  return 'en';
332
+}
333
+
334
+const styles = StyleSheet.create({
335
+  hero: {
336
+    backgroundColor: '#E8E6DA',
337
+    aspectRatio: 16 / 9,
338
+    width: '100%',
339
+  },
340
+  heroImage: {
341
+    width: '100%',
342
+    height: '100%',
343
+  },
344
+  titleContainer: {
345
+    flexDirection: 'row',
346
+    gap: 8,
347
+    paddingHorizontal: 16,
348
+    paddingVertical: 12,
349
+  },
350
+  section: {
351
+    gap: 8,
352
+    marginBottom: 16,
353
+    paddingHorizontal: 16,
354
+  },
355
+  card: {
356
+    gap: 8,
357
+    marginHorizontal: 16,
358
+    padding: 12,
359
+    borderRadius: 14,
360
+    borderWidth: 1,
361
+    borderColor: '#E1DACB',
362
+    backgroundColor: '#FFFFFF',
363
+  },
364
+  featuredImage: {
365
+    height: 180,
366
+    width: '100%',
367
+    borderRadius: 12,
368
+  },
369
+  meta: {
370
+    opacity: 0.7,
371
+  },
372
+  readMore: {
373
+    paddingTop: 4,
374
+  },
375
+  separator: {
376
+    height: 12,
377
+  },
378
+  footer: {
379
+    paddingTop: 8,
380
+    paddingBottom: 24,
381
+    paddingHorizontal: 16,
382
+  },
383
+  debugText: {
384
+    fontSize: 12,
385
+    opacity: 0.7,
386
+    paddingBottom: 8,
387
+  },
388
+  categoryRow: {
389
+    flexDirection: 'row',
390
+    flexWrap: 'wrap',
391
+    gap: 6,
392
+  },
393
+  categoryBadge: {
394
+    borderRadius: 9999,
395
+    borderWidth: 1,
396
+    borderColor: '#D9D1C2',
397
+    backgroundColor: '#FFFFFF',
398
+    paddingHorizontal: 10,
399
+    paddingVertical: 4,
400
+  },
401
+  categoryBadgeText: {
402
+    fontSize: 12,
403
+    fontWeight: '600',
404
+  },
405
+  segmented: {
406
+    flexDirection: 'row',
407
+    flexWrap: 'wrap',
408
+    gap: 8,
409
+  },
410
+  segmentItem: {
411
+    borderRadius: 999,
412
+    borderWidth: 1,
413
+    borderColor: '#C6C6C6',
414
+    paddingHorizontal: 12,
415
+    paddingVertical: 6,
416
+  },
417
+  segmentItemActive: {
418
+    borderColor: '#1B8A5A',
419
+    backgroundColor: '#E7F3EC',
420
+  },
421
+  segmentText: {
422
+    fontSize: 14,
423
+  },
424
+  segmentTextActive: {
425
+    fontWeight: '700',
426
+    color: '#1B8A5A',
427
+  },
428
+});

+ 112 - 0
app/(tabs)/explore.tsx

@@ -0,0 +1,112 @@
1
+import { Image } from 'expo-image';
2
+import { Platform, StyleSheet } from 'react-native';
3
+
4
+import { Collapsible } from '@/components/ui/collapsible';
5
+import { ExternalLink } from '@/components/external-link';
6
+import ParallaxScrollView from '@/components/parallax-scroll-view';
7
+import { ThemedText } from '@/components/themed-text';
8
+import { ThemedView } from '@/components/themed-view';
9
+import { IconSymbol } from '@/components/ui/icon-symbol';
10
+import { Fonts } from '@/constants/theme';
11
+
12
+export default function TabTwoScreen() {
13
+  return (
14
+    <ParallaxScrollView
15
+      headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
16
+      headerImage={
17
+        <IconSymbol
18
+          size={310}
19
+          color="#808080"
20
+          name="chevron.left.forwardslash.chevron.right"
21
+          style={styles.headerImage}
22
+        />
23
+      }>
24
+      <ThemedView style={styles.titleContainer}>
25
+        <ThemedText
26
+          type="title"
27
+          style={{
28
+            fontFamily: Fonts.rounded,
29
+          }}>
30
+          Explore
31
+        </ThemedText>
32
+      </ThemedView>
33
+      <ThemedText>This app includes example code to help you get started.</ThemedText>
34
+      <Collapsible title="File-based routing">
35
+        <ThemedText>
36
+          This app has two screens:{' '}
37
+          <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
38
+          <ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
39
+        </ThemedText>
40
+        <ThemedText>
41
+          The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
42
+          sets up the tab navigator.
43
+        </ThemedText>
44
+        <ExternalLink href="https://docs.expo.dev/router/introduction">
45
+          <ThemedText type="link">Learn more</ThemedText>
46
+        </ExternalLink>
47
+      </Collapsible>
48
+      <Collapsible title="Android, iOS, and web support">
49
+        <ThemedText>
50
+          You can open this project on Android, iOS, and the web. To open the web version, press{' '}
51
+          <ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
52
+        </ThemedText>
53
+      </Collapsible>
54
+      <Collapsible title="Images">
55
+        <ThemedText>
56
+          For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
57
+          <ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
58
+          different screen densities
59
+        </ThemedText>
60
+        <Image
61
+          source={require('@/assets/images/react-logo.png')}
62
+          style={{ width: 100, height: 100, alignSelf: 'center' }}
63
+        />
64
+        <ExternalLink href="https://reactnative.dev/docs/images">
65
+          <ThemedText type="link">Learn more</ThemedText>
66
+        </ExternalLink>
67
+      </Collapsible>
68
+      <Collapsible title="Light and dark mode components">
69
+        <ThemedText>
70
+          This template has light and dark mode support. The{' '}
71
+          <ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
72
+          what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
73
+        </ThemedText>
74
+        <ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
75
+          <ThemedText type="link">Learn more</ThemedText>
76
+        </ExternalLink>
77
+      </Collapsible>
78
+      <Collapsible title="Animations">
79
+        <ThemedText>
80
+          This template includes an example of an animated component. The{' '}
81
+          <ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
82
+          the powerful{' '}
83
+          <ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
84
+            react-native-reanimated
85
+          </ThemedText>{' '}
86
+          library to create a waving hand animation.
87
+        </ThemedText>
88
+        {Platform.select({
89
+          ios: (
90
+            <ThemedText>
91
+              The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
92
+              component provides a parallax effect for the header image.
93
+            </ThemedText>
94
+          ),
95
+        })}
96
+      </Collapsible>
97
+    </ParallaxScrollView>
98
+  );
99
+}
100
+
101
+const styles = StyleSheet.create({
102
+  headerImage: {
103
+    color: '#808080',
104
+    bottom: -90,
105
+    left: -35,
106
+    position: 'absolute',
107
+  },
108
+  titleContainer: {
109
+    flexDirection: 'row',
110
+    gap: 8,
111
+  },
112
+});

+ 488 - 0
app/(tabs)/index.tsx

@@ -0,0 +1,488 @@
1
+import { Image } from 'expo-image';
2
+import { Pressable, StyleSheet, View } from 'react-native';
3
+import { useCallback, useState } from 'react';
4
+import { Link, useFocusEffect } from 'expo-router';
5
+
6
+import ParallaxScrollView from '@/components/parallax-scroll-view';
7
+import { ThemedText } from '@/components/themed-text';
8
+import { ThemedView } from '@/components/themed-view';
9
+import { ThemedButton } from '@/components/themed-button';
10
+import { IconSymbol } from '@/components/ui/icon-symbol';
11
+import { Colors, Fonts } from '@/constants/theme';
12
+import { useTranslation } from '@/localization/i18n';
13
+import { dbPromise, initCoreTables } from '@/services/db';
14
+import { useColorScheme } from '@/hooks/use-color-scheme';
15
+
16
+
17
+export default function HomeScreen() {
18
+  const { t } = useTranslation();
19
+  const theme = useColorScheme() ?? 'light';
20
+  const palette = Colors[theme];
21
+  const iconColor = palette.tint;
22
+  const [counts, setCounts] = useState({
23
+    fields: 0,
24
+    crops: 0,
25
+    observations: 0,
26
+    tasks: 0,
27
+    history: 0,
28
+    harvests: 0,
29
+    sales: 0,
30
+    costs: 0,
31
+  });
32
+
33
+  useFocusEffect(
34
+    useCallback(() => {
35
+      let isActive = true;
36
+
37
+      async function loadCounts() {
38
+        try {
39
+          await initCoreTables();
40
+          const db = await dbPromise;
41
+          const fieldCount = await db.getFirstAsync<{ count: number }>(
42
+            'SELECT COUNT(*) as count FROM fields;'
43
+          );
44
+          const cropCount = await db.getFirstAsync<{ count: number }>(
45
+            'SELECT COUNT(*) as count FROM crops;'
46
+          );
47
+          const observationCount = await db.getFirstAsync<{ count: number }>(
48
+            'SELECT COUNT(*) as count FROM observations;'
49
+          );
50
+          const taskCount = await db.getFirstAsync<{ count: number }>(
51
+            'SELECT COUNT(*) as count FROM daily_tasks WHERE is_active = 1;'
52
+          );
53
+          const historyCount = await db.getFirstAsync<{ count: number }>(
54
+            'SELECT COUNT(*) as count FROM daily_task_entries;'
55
+          );
56
+          const harvestCount = await db.getFirstAsync<{ count: number }>(
57
+            'SELECT COUNT(*) as count FROM harvests;'
58
+          );
59
+          const salesCount = await db.getFirstAsync<{ count: number }>(
60
+            'SELECT COUNT(*) as count FROM sales;'
61
+          );
62
+          const costCount = await db.getFirstAsync<{ count: number }>(
63
+            'SELECT COUNT(*) as count FROM costs;'
64
+          );
65
+          if (!isActive) return;
66
+          setCounts({
67
+            fields: fieldCount?.count ?? 0,
68
+            crops: cropCount?.count ?? 0,
69
+            observations: observationCount?.count ?? 0,
70
+            tasks: taskCount?.count ?? 0,
71
+            history: historyCount?.count ?? 0,
72
+            harvests: harvestCount?.count ?? 0,
73
+            sales: salesCount?.count ?? 0,
74
+            costs: costCount?.count ?? 0,
75
+          });
76
+        } catch {
77
+          if (!isActive) return;
78
+          setCounts((prev) => prev);
79
+        }
80
+      }
81
+
82
+      loadCounts();
83
+      return () => {
84
+        isActive = false;
85
+      };
86
+    }, [])
87
+  );
88
+
89
+  return (
90
+    <ParallaxScrollView
91
+      headerBackgroundColor={{ light: '#E7E1D6', dark: '#1E241D' }}
92
+      headerImage={
93
+        <Image
94
+          source={require('@/assets/images/home.jpg')}
95
+          style={styles.heroImage}
96
+          contentFit="cover"
97
+        />
98
+      }>
99
+      <ThemedView style={styles.heroCopy}>
100
+        <View
101
+          style={[
102
+            styles.heroBadge,
103
+            { borderColor: palette.border, backgroundColor: palette.surface },
104
+          ]}>
105
+          <IconSymbol name="leaf.fill" size={16} color={iconColor} />
106
+          <ThemedText style={styles.heroBadgeText}>{t('home.badge')}</ThemedText>
107
+        </View>
108
+        <ThemedText type="title" style={styles.heroTitle}>
109
+          {t('home.title')}
110
+        </ThemedText>
111
+        <ThemedText style={styles.heroSubtitle}>
112
+          {t('home.subtitle')}
113
+        </ThemedText>
114
+        <View style={styles.heroActions}>
115
+          <Link href="/logbook" asChild>
116
+            <ThemedButton title={t('home.openLogbook')} />
117
+          </Link>
118
+          <Link href="/tasks" asChild>
119
+            <ThemedButton title={t('home.todayTasks')} variant="secondary" />
120
+          </Link>
121
+        </View>
122
+      </ThemedView>
123
+
124
+      <ThemedView style={styles.section}>
125
+        <ThemedText type="subtitle">{t('home.quickActions')}</ThemedText>
126
+        <View style={styles.actionGrid}>
127
+          <Link href="/fields" asChild>
128
+            <Pressable
129
+              style={[
130
+                styles.actionCard,
131
+                { borderColor: palette.border, backgroundColor: palette.card },
132
+                theme === 'light' ? styles.cardLightShadow : null,
133
+              ]}>
134
+              <IconSymbol name="map.fill" size={22} color={iconColor} />
135
+              <View style={styles.actionHeader}>
136
+                <ThemedText style={styles.actionTitle}>{t('home.fields')}</ThemedText>
137
+                <View
138
+                  style={[
139
+                    styles.countBadge,
140
+                    { borderColor: palette.border, backgroundColor: palette.surface },
141
+                  ]}>
142
+                  <ThemedText style={styles.countText}>{counts.fields}</ThemedText>
143
+                </View>
144
+              </View>
145
+              <ThemedText style={styles.actionHint}>{t('home.fieldsHint')}</ThemedText>
146
+            </Pressable>
147
+          </Link>
148
+          <Link href="/crops" asChild>
149
+            <Pressable
150
+              style={[
151
+                styles.actionCard,
152
+                { borderColor: palette.border, backgroundColor: palette.card },
153
+                theme === 'light' ? styles.cardLightShadow : null,
154
+              ]}>
155
+              <IconSymbol name="leaf.circle.fill" size={22} color={iconColor} />
156
+              <View style={styles.actionHeader}>
157
+                <ThemedText style={styles.actionTitle}>{t('home.crops')}</ThemedText>
158
+                <View
159
+                  style={[
160
+                    styles.countBadge,
161
+                    { borderColor: palette.border, backgroundColor: palette.surface },
162
+                  ]}>
163
+                  <ThemedText style={styles.countText}>{counts.crops}</ThemedText>
164
+                </View>
165
+              </View>
166
+              <ThemedText style={styles.actionHint}>{t('home.cropsHint')}</ThemedText>
167
+            </Pressable>
168
+          </Link>
169
+          <Link href="/observations" asChild>
170
+            <Pressable
171
+              style={[
172
+                styles.actionCard,
173
+                { borderColor: palette.border, backgroundColor: palette.card },
174
+                theme === 'light' ? styles.cardLightShadow : null,
175
+              ]}>
176
+              <IconSymbol name="eye.fill" size={22} color={iconColor} />
177
+              <View style={styles.actionHeader}>
178
+                <ThemedText style={styles.actionTitle}>{t('home.observations')}</ThemedText>
179
+                <View
180
+                  style={[
181
+                    styles.countBadge,
182
+                    { borderColor: palette.border, backgroundColor: palette.surface },
183
+                  ]}>
184
+                  <ThemedText style={styles.countText}>{counts.observations}</ThemedText>
185
+                </View>
186
+              </View>
187
+              <ThemedText style={styles.actionHint}>{t('home.observationsHint')}</ThemedText>
188
+            </Pressable>
189
+          </Link>
190
+          <Link href="/onnx" asChild>
191
+            <Pressable
192
+              style={[
193
+                styles.actionCard,
194
+                { borderColor: palette.border, backgroundColor: palette.card },
195
+                theme === 'light' ? styles.cardLightShadow : null,
196
+              ]}>
197
+              <IconSymbol name="sparkles" size={22} color={iconColor} />
198
+              <ThemedText style={styles.actionTitle}>{t('home.onnx')}</ThemedText>
199
+              <ThemedText style={styles.actionHint}>{t('home.onnxHint')}</ThemedText>
200
+            </Pressable>
201
+          </Link>
202
+          <Link href="/harvests" asChild>
203
+            <Pressable
204
+              style={[
205
+                styles.actionCard,
206
+                { borderColor: palette.border, backgroundColor: palette.card },
207
+                theme === 'light' ? styles.cardLightShadow : null,
208
+              ]}>
209
+              <IconSymbol name="tray.full.fill" size={22} color={iconColor} />
210
+              <View style={styles.actionHeader}>
211
+                <ThemedText style={styles.actionTitle}>{t('home.harvests')}</ThemedText>
212
+                <View
213
+                  style={[
214
+                    styles.countBadge,
215
+                    { borderColor: palette.border, backgroundColor: palette.surface },
216
+                  ]}>
217
+                  <ThemedText style={styles.countText}>{counts.harvests}</ThemedText>
218
+                </View>
219
+              </View>
220
+              <ThemedText style={styles.actionHint}>{t('home.harvestsHint')}</ThemedText>
221
+            </Pressable>
222
+          </Link>
223
+          <Link href="/sales" asChild>
224
+            <Pressable
225
+              style={[
226
+                styles.actionCard,
227
+                { borderColor: palette.border, backgroundColor: palette.card },
228
+                theme === 'light' ? styles.cardLightShadow : null,
229
+              ]}>
230
+              <IconSymbol name="cart.fill" size={22} color={iconColor} />
231
+              <View style={styles.actionHeader}>
232
+                <ThemedText style={styles.actionTitle}>{t('home.sales')}</ThemedText>
233
+                <View
234
+                  style={[
235
+                    styles.countBadge,
236
+                    { borderColor: palette.border, backgroundColor: palette.surface },
237
+                  ]}>
238
+                  <ThemedText style={styles.countText}>{counts.sales}</ThemedText>
239
+                </View>
240
+              </View>
241
+              <ThemedText style={styles.actionHint}>{t('home.salesHint')}</ThemedText>
242
+            </Pressable>
243
+          </Link>
244
+          <Link href="/costs" asChild>
245
+            <Pressable
246
+              style={[
247
+                styles.actionCard,
248
+                { borderColor: palette.border, backgroundColor: palette.card },
249
+                theme === 'light' ? styles.cardLightShadow : null,
250
+              ]}>
251
+              <IconSymbol name="dollarsign.circle.fill" size={22} color={iconColor} />
252
+              <View style={styles.actionHeader}>
253
+                <ThemedText style={styles.actionTitle}>{t('home.costs')}</ThemedText>
254
+                <View
255
+                  style={[
256
+                    styles.countBadge,
257
+                    { borderColor: palette.border, backgroundColor: palette.surface },
258
+                  ]}>
259
+                  <ThemedText style={styles.countText}>{counts.costs}</ThemedText>
260
+                </View>
261
+              </View>
262
+              <ThemedText style={styles.actionHint}>{t('home.costsHint')}</ThemedText>
263
+            </Pressable>
264
+          </Link>
265
+        </View>
266
+      </ThemedView>
267
+
268
+      <ThemedView style={styles.section}>
269
+        <ThemedText type="subtitle">{t('home.todayTitle')}</ThemedText>
270
+        <View
271
+          style={[
272
+            styles.highlightCard,
273
+            { borderColor: palette.border, backgroundColor: palette.surface },
274
+          ]}>
275
+          <View style={styles.highlightHeader}>
276
+            <ThemedText style={styles.highlightTitle}>{t('home.todayCardTitle')}</ThemedText>
277
+            <View style={styles.highlightCounts}>
278
+              <View
279
+                style={[
280
+                  styles.countBadge,
281
+                  { borderColor: palette.border, backgroundColor: palette.card },
282
+                ]}>
283
+                <ThemedText style={styles.countText}>
284
+                  {t('home.count.tasks')} {counts.tasks}
285
+                </ThemedText>
286
+              </View>
287
+              <View
288
+                style={[
289
+                  styles.countBadge,
290
+                  { borderColor: palette.border, backgroundColor: palette.card },
291
+                ]}>
292
+                <ThemedText style={styles.countText}>
293
+                  {t('home.count.history')} {counts.history}
294
+                </ThemedText>
295
+              </View>
296
+            </View>
297
+          </View>
298
+          <ThemedText style={styles.highlightBody}>
299
+            {t('home.todayCardBody')}
300
+          </ThemedText>
301
+          <View style={styles.highlightActions}>
302
+            <Link href="/tasks" asChild>
303
+              <ThemedButton title={t('home.openTasks')} />
304
+            </Link>
305
+            <Link href="/task-history" asChild>
306
+              <ThemedButton title={t('home.taskHistory')} variant="secondary" />
307
+            </Link>
308
+          </View>
309
+        </View>
310
+      </ThemedView>
311
+
312
+      <ThemedView style={styles.section}>
313
+        <ThemedText type="subtitle">{t('home.learnAnalyze')}</ThemedText>
314
+        <View style={styles.actionGrid}>
315
+          <Link href="/blog" asChild>
316
+            <Pressable
317
+              style={[
318
+                styles.actionCardWide,
319
+                { borderColor: palette.border, backgroundColor: palette.card },
320
+                theme === 'light' ? styles.cardLightShadow : null,
321
+              ]}>
322
+              <IconSymbol name="doc.text.image" size={22} color={iconColor} />
323
+              <View style={styles.actionCopy}>
324
+                <ThemedText style={styles.actionTitle}>{t('home.blogs')}</ThemedText>
325
+                <ThemedText style={styles.actionHint}>{t('home.blogsHint')}</ThemedText>
326
+              </View>
327
+            </Pressable>
328
+          </Link>
329
+          <Link href="/setup" asChild>
330
+            <Pressable
331
+              style={[
332
+                styles.actionCardWide,
333
+                { borderColor: palette.border, backgroundColor: palette.card },
334
+                theme === 'light' ? styles.cardLightShadow : null,
335
+              ]}>
336
+              <IconSymbol name="person.crop.circle" size={22} color={iconColor} />
337
+              <View style={styles.actionCopy}>
338
+                <ThemedText style={styles.actionTitle}>{t('home.profile')}</ThemedText>
339
+                <ThemedText style={styles.actionHint}>{t('home.profileHint')}</ThemedText>
340
+              </View>
341
+            </Pressable>
342
+          </Link>
343
+        </View>
344
+      </ThemedView>
345
+    </ParallaxScrollView>
346
+  );
347
+}
348
+
349
+const styles = StyleSheet.create({
350
+  heroImage: {
351
+    height: '100%',
352
+    width: '100%',
353
+    position: 'absolute',
354
+  },
355
+  heroCopy: {
356
+    gap: 12,
357
+    paddingHorizontal: 4,
358
+  },
359
+  heroBadge: {
360
+    alignSelf: 'flex-start',
361
+    flexDirection: 'row',
362
+    gap: 6,
363
+    paddingHorizontal: 10,
364
+    paddingVertical: 6,
365
+    borderRadius: 999,
366
+    borderWidth: 1,
367
+    borderColor: '#D2C9B8',
368
+    backgroundColor: '#F6F1E8',
369
+  },
370
+  heroBadgeText: {
371
+    fontSize: 12,
372
+    fontWeight: '600',
373
+  },
374
+  heroTitle: {
375
+    fontFamily: Fonts.rounded,
376
+    fontSize: 28,
377
+  },
378
+  heroSubtitle: {
379
+    opacity: 0.8,
380
+    fontSize: 15,
381
+    lineHeight: 22,
382
+  },
383
+  heroActions: {
384
+    flexDirection: 'row',
385
+    flexWrap: 'wrap',
386
+    gap: 12,
387
+  },
388
+  section: {
389
+    gap: 12,
390
+  },
391
+  actionGrid: {
392
+    flexDirection: 'row',
393
+    flexWrap: 'wrap',
394
+    columnGap: 20,
395
+    rowGap: 16,
396
+  },
397
+  actionCard: {
398
+    flexBasis: '49%',
399
+    flexGrow: 1,
400
+    gap: 6,
401
+    borderRadius: 14,
402
+    borderWidth: 1,
403
+    borderColor: '#D9D1C2',
404
+    padding: 12,
405
+    backgroundColor: '#FBF8F1',
406
+    alignItems: 'center',
407
+  },
408
+  actionCardWide: {
409
+    flexDirection: 'row',
410
+    alignItems: 'center',
411
+    gap: 12,
412
+    borderRadius: 14,
413
+    borderWidth: 1,
414
+    borderColor: '#D9D1C2',
415
+    padding: 12,
416
+    backgroundColor: '#FBF8F1',
417
+    flexBasis: '100%',
418
+  },
419
+  actionCopy: {
420
+    gap: 4,
421
+  },
422
+  actionHeader: {
423
+    flexDirection: 'row',
424
+    alignItems: 'center',
425
+    justifyContent: 'space-between',
426
+    gap: 8,
427
+  },
428
+  actionTitle: {
429
+    fontSize: 16,
430
+    fontWeight: '600',
431
+  },
432
+  actionHint: {
433
+    fontSize: 12,
434
+    opacity: 0.7,
435
+  },
436
+  highlightCard: {
437
+    borderRadius: 16,
438
+    borderWidth: 1,
439
+    borderColor: '#D6CCB9',
440
+    backgroundColor: '#EFE7D6',
441
+    padding: 16,
442
+    gap: 10,
443
+  },
444
+  highlightHeader: {
445
+    gap: 8,
446
+  },
447
+  highlightCounts: {
448
+    flexDirection: 'row',
449
+    flexWrap: 'wrap',
450
+    gap: 8,
451
+  },
452
+  highlightTitle: {
453
+    fontSize: 18,
454
+    fontWeight: '700',
455
+  },
456
+  highlightBody: {
457
+    fontSize: 14,
458
+    lineHeight: 20,
459
+    opacity: 0.8,
460
+  },
461
+  highlightActions: {
462
+    flexDirection: 'row',
463
+    flexWrap: 'wrap',
464
+    gap: 12,
465
+  },
466
+  countBadge: {
467
+    minWidth: 28,
468
+    borderRadius: 999,
469
+    borderWidth: 1,
470
+    borderColor: '#D9D1C2',
471
+    backgroundColor: '#F6F1E8',
472
+    alignItems: 'center',
473
+    justifyContent: 'center',
474
+    paddingHorizontal: 8,
475
+    paddingVertical: 4,
476
+  },
477
+  countText: {
478
+    fontSize: 11,
479
+    fontWeight: '600',
480
+  },
481
+  cardLightShadow: {
482
+    shadowColor: '#1E1A12',
483
+    shadowOpacity: 0.08,
484
+    shadowRadius: 10,
485
+    shadowOffset: { width: 0, height: 6 },
486
+    elevation: 2,
487
+  },
488
+});

+ 424 - 0
app/(tabs)/logbook.tsx

@@ -0,0 +1,424 @@
1
+import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
2
+import { useEffect, useState } from 'react';
3
+import { Image } from 'expo-image';
4
+
5
+import { ThemedText } from '@/components/themed-text';
6
+import { ThemedView } from '@/components/themed-view';
7
+import { IconSymbol } from '@/components/ui/icon-symbol';
8
+import { Colors, Fonts } from '@/constants/theme';
9
+import { Link } from 'expo-router';
10
+import { useTranslation } from '@/localization/i18n';
11
+import { dbPromise, initCoreTables } from '@/services/db';
12
+import { useColorScheme } from '@/hooks/use-color-scheme';
13
+
14
+export default function LogbookScreen() {
15
+  const { t } = useTranslation();
16
+  const theme = useColorScheme() ?? 'light';
17
+  const palette = Colors[theme];
18
+  const [counts, setCounts] = useState({
19
+    fields: 0,
20
+    crops: 0,
21
+    observations: 0,
22
+    tasks: 0,
23
+    history: 0,
24
+    harvests: 0,
25
+    sales: 0,
26
+    costs: 0,
27
+  });
28
+
29
+  useEffect(() => {
30
+    let isActive = true;
31
+
32
+    async function loadCounts() {
33
+      try {
34
+        await initCoreTables();
35
+        const db = await dbPromise;
36
+        const fieldCount = await db.getFirstAsync<{ count: number }>(
37
+          'SELECT COUNT(*) as count FROM fields;'
38
+        );
39
+        const cropCount = await db.getFirstAsync<{ count: number }>(
40
+          'SELECT COUNT(*) as count FROM crops;'
41
+        );
42
+        const observationCount = await db.getFirstAsync<{ count: number }>(
43
+          'SELECT COUNT(*) as count FROM observations;'
44
+        );
45
+        const taskCount = await db.getFirstAsync<{ count: number }>(
46
+          'SELECT COUNT(*) as count FROM daily_tasks WHERE is_active = 1;'
47
+        );
48
+        const historyCount = await db.getFirstAsync<{ count: number }>(
49
+          'SELECT COUNT(*) as count FROM daily_task_entries;'
50
+        );
51
+        const harvestCount = await db.getFirstAsync<{ count: number }>(
52
+          'SELECT COUNT(*) as count FROM harvests;'
53
+        );
54
+        const salesCount = await db.getFirstAsync<{ count: number }>(
55
+          'SELECT COUNT(*) as count FROM sales;'
56
+        );
57
+        const costCount = await db.getFirstAsync<{ count: number }>(
58
+          'SELECT COUNT(*) as count FROM costs;'
59
+        );
60
+        if (!isActive) return;
61
+        setCounts({
62
+          fields: fieldCount?.count ?? 0,
63
+          crops: cropCount?.count ?? 0,
64
+          observations: observationCount?.count ?? 0,
65
+          tasks: taskCount?.count ?? 0,
66
+          history: historyCount?.count ?? 0,
67
+          harvests: harvestCount?.count ?? 0,
68
+          sales: salesCount?.count ?? 0,
69
+          costs: costCount?.count ?? 0,
70
+        });
71
+      } catch {
72
+        if (!isActive) return;
73
+        setCounts((prev) => prev);
74
+      }
75
+    }
76
+
77
+    loadCounts();
78
+    return () => {
79
+      isActive = false;
80
+    };
81
+  }, []);
82
+
83
+  return (
84
+    <ThemedView style={styles.container}>
85
+      <ScrollView contentContainerStyle={styles.scrollContent}>
86
+        <ThemedView style={styles.hero}>
87
+          <Image
88
+            source={require('@/assets/images/logbook.jpg')}
89
+            style={styles.heroImage}
90
+            contentFit="cover"
91
+          />
92
+        </ThemedView>
93
+        <ThemedView style={styles.titleContainer}>
94
+          <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
95
+            {t('logbook.title')}
96
+          </ThemedText>
97
+          <ThemedText style={styles.subtitle}>{t('logbook.subtitle')}</ThemedText>
98
+        </ThemedView>
99
+
100
+        <View style={styles.grid}>
101
+          <Link href="/fields" asChild>
102
+            <Pressable
103
+              style={[
104
+                styles.card,
105
+                {
106
+                  backgroundColor: palette.card,
107
+                  borderColor: palette.border,
108
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
109
+                },
110
+              ]}>
111
+              <View style={styles.cardHeader}>
112
+                <View style={styles.cardTitleRow}>
113
+                  <View
114
+                    style={[
115
+                      styles.cardIcon,
116
+                      { backgroundColor: palette.surface },
117
+                      theme === 'light' ? styles.cardIconLight : null,
118
+                    ]}>
119
+                    <IconSymbol size={18} color={palette.tint} name="square.grid.2x2.fill" />
120
+                  </View>
121
+                  <ThemedText type="subtitle">{t('logbook.fields')}</ThemedText>
122
+                </View>
123
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
124
+                  <ThemedText style={styles.countText}>{counts.fields}</ThemedText>
125
+                </View>
126
+              </View>
127
+              <ThemedText style={styles.cardHint}>{t('logbook.fieldsHint')}</ThemedText>
128
+            </Pressable>
129
+          </Link>
130
+          <Link href="/crops" asChild>
131
+            <Pressable
132
+              style={[
133
+                styles.card,
134
+                {
135
+                  backgroundColor: palette.card,
136
+                  borderColor: palette.border,
137
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
138
+                },
139
+              ]}>
140
+              <View style={styles.cardHeader}>
141
+                <View style={styles.cardTitleRow}>
142
+                  <View
143
+                    style={[
144
+                      styles.cardIcon,
145
+                      { backgroundColor: palette.surface },
146
+                      theme === 'light' ? styles.cardIconLight : null,
147
+                    ]}>
148
+                    <IconSymbol size={18} color={palette.tint} name="leaf.fill" />
149
+                  </View>
150
+                  <ThemedText type="subtitle">{t('logbook.crops')}</ThemedText>
151
+                </View>
152
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
153
+                  <ThemedText style={styles.countText}>{counts.crops}</ThemedText>
154
+                </View>
155
+              </View>
156
+              <ThemedText style={styles.cardHint}>{t('logbook.cropsHint')}</ThemedText>
157
+            </Pressable>
158
+          </Link>
159
+          <Link href="/observations" asChild>
160
+            <Pressable
161
+              style={[
162
+                styles.card,
163
+                {
164
+                  backgroundColor: palette.card,
165
+                  borderColor: palette.border,
166
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
167
+                },
168
+              ]}>
169
+              <View style={styles.cardHeader}>
170
+                <View style={styles.cardTitleRow}>
171
+                  <View
172
+                    style={[
173
+                      styles.cardIcon,
174
+                      { backgroundColor: palette.surface },
175
+                      theme === 'light' ? styles.cardIconLight : null,
176
+                    ]}>
177
+                    <IconSymbol size={18} color={palette.tint} name="eye.fill" />
178
+                  </View>
179
+                  <ThemedText type="subtitle">{t('logbook.observations')}</ThemedText>
180
+                </View>
181
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
182
+                  <ThemedText style={styles.countText}>{counts.observations}</ThemedText>
183
+                </View>
184
+              </View>
185
+              <ThemedText style={styles.cardHint}>{t('logbook.observationsHint')}</ThemedText>
186
+            </Pressable>
187
+          </Link>
188
+          <Link href="/tasks" asChild>
189
+            <Pressable
190
+              style={[
191
+                styles.card,
192
+                {
193
+                  backgroundColor: palette.card,
194
+                  borderColor: palette.border,
195
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
196
+                },
197
+              ]}>
198
+              <View style={styles.cardHeader}>
199
+                <View style={styles.cardTitleRow}>
200
+                  <View
201
+                    style={[
202
+                      styles.cardIcon,
203
+                      { backgroundColor: palette.surface },
204
+                      theme === 'light' ? styles.cardIconLight : null,
205
+                    ]}>
206
+                    <IconSymbol size={18} color={palette.tint} name="checklist" />
207
+                  </View>
208
+                  <ThemedText type="subtitle">{t('logbook.tasks')}</ThemedText>
209
+                </View>
210
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
211
+                  <ThemedText style={styles.countText}>{counts.tasks}</ThemedText>
212
+                </View>
213
+              </View>
214
+              <ThemedText style={styles.cardHint}>{t('logbook.tasksHint')}</ThemedText>
215
+            </Pressable>
216
+          </Link>
217
+          <Link href="/task-history" asChild>
218
+            <Pressable
219
+              style={[
220
+                styles.card,
221
+                {
222
+                  backgroundColor: palette.card,
223
+                  borderColor: palette.border,
224
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
225
+                },
226
+              ]}>
227
+              <View style={styles.cardHeader}>
228
+                <View style={styles.cardTitleRow}>
229
+                  <View
230
+                    style={[
231
+                      styles.cardIcon,
232
+                      { backgroundColor: palette.surface },
233
+                      theme === 'light' ? styles.cardIconLight : null,
234
+                    ]}>
235
+                    <IconSymbol size={18} color={palette.tint} name="clock.fill" />
236
+                  </View>
237
+                  <ThemedText type="subtitle">{t('logbook.history')}</ThemedText>
238
+                </View>
239
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
240
+                  <ThemedText style={styles.countText}>{counts.history}</ThemedText>
241
+                </View>
242
+              </View>
243
+              <ThemedText style={styles.cardHint}>{t('logbook.historyHint')}</ThemedText>
244
+            </Pressable>
245
+          </Link>
246
+          <Link href="/harvests" asChild>
247
+            <Pressable
248
+              style={[
249
+                styles.card,
250
+                {
251
+                  backgroundColor: palette.card,
252
+                  borderColor: palette.border,
253
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
254
+                },
255
+              ]}>
256
+              <View style={styles.cardHeader}>
257
+                <View style={styles.cardTitleRow}>
258
+                  <View
259
+                    style={[
260
+                      styles.cardIcon,
261
+                      { backgroundColor: palette.surface },
262
+                      theme === 'light' ? styles.cardIconLight : null,
263
+                    ]}>
264
+                    <IconSymbol size={18} color={palette.tint} name="basket.fill" />
265
+                  </View>
266
+                  <ThemedText type="subtitle">{t('logbook.harvests')}</ThemedText>
267
+                </View>
268
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
269
+                  <ThemedText style={styles.countText}>{counts.harvests}</ThemedText>
270
+                </View>
271
+              </View>
272
+              <ThemedText style={styles.cardHint}>{t('logbook.harvestsHint')}</ThemedText>
273
+            </Pressable>
274
+          </Link>
275
+          <Link href="/sales" asChild>
276
+            <Pressable
277
+              style={[
278
+                styles.card,
279
+                {
280
+                  backgroundColor: palette.card,
281
+                  borderColor: palette.border,
282
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
283
+                },
284
+              ]}>
285
+              <View style={styles.cardHeader}>
286
+                <View style={styles.cardTitleRow}>
287
+                  <View
288
+                    style={[
289
+                      styles.cardIcon,
290
+                      { backgroundColor: palette.surface },
291
+                      theme === 'light' ? styles.cardIconLight : null,
292
+                    ]}>
293
+                    <IconSymbol size={18} color={palette.tint} name="cart.fill" />
294
+                  </View>
295
+                  <ThemedText type="subtitle">{t('logbook.sales')}</ThemedText>
296
+                </View>
297
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
298
+                  <ThemedText style={styles.countText}>{counts.sales}</ThemedText>
299
+                </View>
300
+              </View>
301
+              <ThemedText style={styles.cardHint}>{t('logbook.salesHint')}</ThemedText>
302
+            </Pressable>
303
+          </Link>
304
+          <Link href="/costs" asChild>
305
+            <Pressable
306
+              style={[
307
+                styles.card,
308
+                {
309
+                  backgroundColor: palette.card,
310
+                  borderColor: palette.border,
311
+                  ...(theme === 'light' ? styles.cardLightShadow : null),
312
+                },
313
+              ]}>
314
+              <View style={styles.cardHeader}>
315
+                <View style={styles.cardTitleRow}>
316
+                  <View
317
+                    style={[
318
+                      styles.cardIcon,
319
+                      { backgroundColor: palette.surface },
320
+                      theme === 'light' ? styles.cardIconLight : null,
321
+                    ]}>
322
+                    <IconSymbol size={18} color={palette.tint} name="creditcard.fill" />
323
+                  </View>
324
+                  <ThemedText type="subtitle">{t('logbook.costs')}</ThemedText>
325
+                </View>
326
+                <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.surface }]}>
327
+                  <ThemedText style={styles.countText}>{counts.costs}</ThemedText>
328
+                </View>
329
+              </View>
330
+              <ThemedText style={styles.cardHint}>{t('logbook.costsHint')}</ThemedText>
331
+            </Pressable>
332
+          </Link>
333
+        </View>
334
+      </ScrollView>
335
+    </ThemedView>
336
+  );
337
+}
338
+
339
+const styles = StyleSheet.create({
340
+  container: {
341
+    flex: 1,
342
+  },
343
+  scrollContent: {
344
+    paddingBottom: 24,
345
+  },
346
+  hero: {
347
+    backgroundColor: '#E8E6DA',
348
+    aspectRatio: 16 / 9,
349
+    width: '100%',
350
+  },
351
+  heroImage: {
352
+    width: '100%',
353
+    height: '100%',
354
+  },
355
+  titleContainer: {
356
+    gap: 8,
357
+    paddingHorizontal: 16,
358
+    paddingVertical: 12,
359
+  },
360
+  subtitle: {
361
+    opacity: 0.7,
362
+  },
363
+  grid: {
364
+    paddingHorizontal: 16,
365
+    gap: 12,
366
+  },
367
+  card: {
368
+    borderRadius: 12,
369
+    borderWidth: 1,
370
+    borderColor: '#C6C6C6',
371
+    padding: 16,
372
+    backgroundColor: '#FFFFFF',
373
+  },
374
+  cardHeader: {
375
+    flexDirection: 'row',
376
+    alignItems: 'center',
377
+    justifyContent: 'space-between',
378
+    gap: 12,
379
+  },
380
+  cardTitleRow: {
381
+    flexDirection: 'row',
382
+    alignItems: 'center',
383
+    gap: 10,
384
+    flex: 1,
385
+  },
386
+  cardIcon: {
387
+    width: 28,
388
+    height: 28,
389
+    borderRadius: 8,
390
+    backgroundColor: '#E7F3EA',
391
+    alignItems: 'center',
392
+    justifyContent: 'center',
393
+  },
394
+  countBadge: {
395
+    minWidth: 28,
396
+    height: 28,
397
+    borderRadius: 14,
398
+    borderWidth: 1,
399
+    borderColor: '#D9D1C2',
400
+    backgroundColor: '#F6F1E8',
401
+    alignItems: 'center',
402
+    justifyContent: 'center',
403
+    paddingHorizontal: 8,
404
+  },
405
+  countText: {
406
+    fontSize: 12,
407
+    fontWeight: '600',
408
+  },
409
+  cardHint: {
410
+    opacity: 0.7,
411
+    marginTop: 6,
412
+  },
413
+  cardLightShadow: {
414
+    shadowColor: '#1E1A12',
415
+    shadowOpacity: 0.08,
416
+    shadowRadius: 10,
417
+    shadowOffset: { width: 0, height: 6 },
418
+    elevation: 2,
419
+  },
420
+  cardIconLight: {
421
+    borderWidth: 1,
422
+    borderColor: '#E0D7C9',
423
+  },
424
+});

+ 537 - 0
app/(tabs)/onnx.tsx

@@ -0,0 +1,537 @@
1
+import { useEffect, useMemo, useState } from 'react';
2
+import { StyleSheet, View } from 'react-native';
3
+import { Image } from 'expo-image';
4
+import * as ImagePicker from 'expo-image-picker';
5
+import * as ImageManipulator from 'expo-image-manipulator';
6
+import jpeg from 'jpeg-js';
7
+
8
+import ParallaxScrollView from '@/components/parallax-scroll-view';
9
+import { ThemedText } from '@/components/themed-text';
10
+import { ThemedView } from '@/components/themed-view';
11
+import { IconSymbol } from '@/components/ui/icon-symbol';
12
+import { Colors, Fonts } from '@/constants/theme';
13
+import { useTranslation } from '@/localization/i18n';
14
+import { ThemedButton } from '@/components/themed-button';
15
+import { dbPromise, initCoreTables } from '@/services/db';
16
+import { useColorScheme } from '@/hooks/use-color-scheme';
17
+
18
+const classNames = require('@/assets/class_names.json') as string[];
19
+
20
+
21
+type Prediction = {
22
+  index: number;
23
+  label: string;
24
+  score: number;
25
+};
26
+
27
+type InferenceLog = {
28
+  id: number;
29
+  top_label: string | null;
30
+  top_score: number | null;
31
+  created_at: string | null;
32
+  uri: string | null;
33
+};
34
+
35
+export default function OnnxScreen() {
36
+  const { t } = useTranslation();
37
+  const theme = useColorScheme() ?? 'light';
38
+  const palette = Colors[theme];
39
+  const [status, setStatus] = useState(t('onnx.status.pick'));
40
+  const [imageUri, setImageUri] = useState<string | null>(null);
41
+  const [predictions, setPredictions] = useState<Prediction[]>([]);
42
+  const [logs, setLogs] = useState<InferenceLog[]>([]);
43
+
44
+  const canRun = useMemo(() => Boolean(imageUri), [imageUri]);
45
+
46
+  useEffect(() => {
47
+    let active = true;
48
+    initCoreTables()
49
+      .then(async () => {
50
+        const rows = await loadLogs();
51
+        if (active) setLogs(rows);
52
+      })
53
+      .catch((error) => {
54
+        setStatus(`Error: ${String(error)}`);
55
+      });
56
+    return () => {
57
+      active = false;
58
+    };
59
+  }, []);
60
+
61
+  async function pickImage() {
62
+    const result = await ImagePicker.launchImageLibraryAsync({
63
+      mediaTypes: getImageMediaTypes(),
64
+      quality: 1,
65
+    });
66
+
67
+    if (result.canceled) return;
68
+    const asset = result.assets[0];
69
+    setImageUri(asset.uri);
70
+    setPredictions([]);
71
+    setStatus(t('onnx.status.ready'));
72
+  }
73
+
74
+  async function handleRun() {
75
+    if (!imageUri) return;
76
+
77
+    setStatus(t('onnx.status.preprocessing'));
78
+    try {
79
+      const { runPlantVillageInference } = await import('@/services/onnx/runPlantVillage');
80
+      const inputTensor = await imageToTensor(imageUri);
81
+      setStatus(t('onnx.status.running'));
82
+      const scores = await runPlantVillageInference(inputTensor);
83
+      const probabilities = softmax(scores);
84
+      const top = topK(probabilities, 3).map((item) => ({
85
+        ...item,
86
+        label: humanizeLabel(classNames[item.index] ?? `Class ${item.index}`),
87
+      }));
88
+      setPredictions(top);
89
+      await logInference({
90
+        uri: imageUri,
91
+        top,
92
+        scores: probabilities,
93
+      });
94
+      setLogs(await loadLogs());
95
+      setStatus(t('onnx.status.done'));
96
+    } catch (error) {
97
+      const message = String(error);
98
+      if (message.includes('install') || message.includes('onnxruntime')) {
99
+        setStatus(t('onnx.status.nativeMissing'));
100
+      } else {
101
+        setStatus(`Error: ${message}`);
102
+      }
103
+    }
104
+  }
105
+
106
+  return (
107
+    <ParallaxScrollView
108
+      headerBackgroundColor={{ light: '#E9E4DB', dark: '#2A2A2A' }}
109
+      headerImage={
110
+        <Image
111
+          source={require('@/assets/images/leafscan.jpg')}
112
+          style={styles.headerImage}
113
+          contentFit="cover"
114
+        />
115
+      }>
116
+      <ThemedView style={[styles.hero, { backgroundColor: palette.surface, borderColor: palette.border }]}>
117
+        <View style={[styles.heroIcon, { backgroundColor: palette.card }]}>
118
+          <IconSymbol size={44} color={palette.tint} name="leaf.fill" />
119
+        </View>
120
+        <View style={styles.heroText}>
121
+          <ThemedText type="title" style={styles.heroTitle}>
122
+            {t('onnx.title')}
123
+          </ThemedText>
124
+          <ThemedText style={[styles.heroSubtitle, { color: palette.muted }]}>
125
+            {t('onnx.howBody')}
126
+          </ThemedText>
127
+        </View>
128
+      </ThemedView>
129
+
130
+      <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
131
+        <View style={styles.cardHeader}>
132
+          <ThemedText type="subtitle">{t('onnx.testTitle')}</ThemedText>
133
+          <View style={[styles.statusPill, { backgroundColor: palette.surface }]}>
134
+            <ThemedText style={[styles.statusText, { color: palette.muted }]}>{status}</ThemedText>
135
+          </View>
136
+        </View>
137
+        {!imageUri ? (
138
+          <ThemedText style={[styles.inlineHint, { color: palette.muted }]}>
139
+            Pick an image to run the model.
140
+          </ThemedText>
141
+        ) : null}
142
+        <View
143
+          style={[
144
+            styles.previewWrap,
145
+            { backgroundColor: palette.surface, borderColor: palette.border },
146
+          ]}>
147
+          {imageUri ? (
148
+            <Image source={{ uri: imageUri }} style={styles.preview} contentFit="cover" />
149
+          ) : (
150
+            <View style={styles.previewPlaceholder}>
151
+              <IconSymbol size={36} color={palette.muted} name="photo.on.rectangle.angled" />
152
+              <ThemedText style={[styles.placeholderText, { color: palette.muted }]}>
153
+                {t('onnx.status.pick')}
154
+              </ThemedText>
155
+            </View>
156
+          )}
157
+        </View>
158
+        <View style={styles.actionRow}>
159
+          <ThemedButton title={t('onnx.pickImage')} onPress={pickImage} variant="secondary" />
160
+          <ThemedButton title={t('onnx.runModel')} onPress={handleRun} disabled={!canRun} />
161
+        </View>
162
+      </ThemedView>
163
+
164
+      {predictions.length > 0 ? (
165
+        <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
166
+          <ThemedText type="subtitle">{t('onnx.topPredictions')}</ThemedText>
167
+          <View style={styles.results}>
168
+            {predictions.map((pred) => {
169
+              const percent = Number((pred.score * 100).toFixed(2));
170
+              return (
171
+                <View key={pred.index} style={styles.predRow}>
172
+                  <View style={styles.predLabelRow}>
173
+                    <ThemedText style={styles.predLabel}>{pred.label}</ThemedText>
174
+                    <ThemedText style={styles.predPercent}>{percent}%</ThemedText>
175
+                  </View>
176
+                  <View style={styles.barTrack}>
177
+                    <View style={[styles.barFill, { width: `${Math.min(percent, 100)}%` }]} />
178
+                  </View>
179
+                </View>
180
+              );
181
+            })}
182
+          </View>
183
+        </ThemedView>
184
+      ) : null}
185
+
186
+      {logs.length > 0 ? (
187
+        <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
188
+          <ThemedText type="subtitle">Recent scans</ThemedText>
189
+          <View style={styles.logList}>
190
+            {logs.map((item) => {
191
+              const score = item.top_score ? Math.round(item.top_score * 100) : null;
192
+              return (
193
+                  <View key={item.id} style={[styles.logRow, { backgroundColor: palette.surface }]}>
194
+                  <View style={[styles.logThumb, { backgroundColor: palette.card }]}>
195
+                    {item.uri ? (
196
+                      <Image source={{ uri: item.uri }} style={styles.logImage} contentFit="cover" />
197
+                    ) : (
198
+                      <IconSymbol size={18} color={palette.muted} name="photo" />
199
+                    )}
200
+                  </View>
201
+                  <View style={styles.logMeta}>
202
+                    <ThemedText style={styles.logLabel}>
203
+                      {item.top_label ?? t('onnx.topPredictions')}
204
+                    </ThemedText>
205
+                    <ThemedText style={[styles.logSub, { color: palette.muted }]}>
206
+                      {item.created_at ? formatDate(item.created_at) : ''}
207
+                    </ThemedText>
208
+                  </View>
209
+                  <ThemedText style={[styles.logScore, { color: palette.tint }]}>
210
+                    {score !== null ? `${score}%` : '--'}
211
+                  </ThemedText>
212
+                </View>
213
+              );
214
+            })}
215
+          </View>
216
+        </ThemedView>
217
+      ) : null}
218
+
219
+      <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
220
+        <ThemedText type="subtitle">{t('onnx.howTitle')}</ThemedText>
221
+        <ThemedText>{t('onnx.howBody')}</ThemedText>
222
+      </ThemedView>
223
+
224
+    </ParallaxScrollView>
225
+  );
226
+}
227
+
228
+function getImageMediaTypes() {
229
+  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
230
+    .MediaType;
231
+  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
232
+}
233
+
234
+function topK(scores: Float32Array, k: number): Prediction[] {
235
+  const items = Array.from(scores).map((score, index) => ({
236
+    index,
237
+    label: `Class ${index}`,
238
+    score,
239
+  }));
240
+  items.sort((a, b) => b.score - a.score);
241
+  return items.slice(0, k);
242
+}
243
+
244
+function softmax(scores: Float32Array) {
245
+  const max = Math.max(...scores);
246
+  const exps = Array.from(scores).map((value) => Math.exp(value - max));
247
+  const sum = exps.reduce((acc, value) => acc + value, 0);
248
+  return new Float32Array(exps.map((value) => value / sum));
249
+}
250
+
251
+async function imageToTensor(uri: string) {
252
+  const resized = await ImageManipulator.manipulateAsync(
253
+    uri,
254
+    [{ resize: { width: 224, height: 224 } }],
255
+    {
256
+      base64: true,
257
+      format: ImageManipulator.SaveFormat.JPEG,
258
+      compress: 1,
259
+    }
260
+  );
261
+
262
+  if (!resized.base64) {
263
+    throw new Error('Failed to read image data.');
264
+  }
265
+
266
+  const bytes = base64ToUint8Array(resized.base64);
267
+  const decoded = jpeg.decode(bytes, { useTArray: true });
268
+  const { data, width, height } = decoded;
269
+
270
+  if (width !== 224 || height !== 224) {
271
+    throw new Error(`Unexpected image size: ${width}x${height}`);
272
+  }
273
+
274
+  const size = width * height;
275
+  const floatData = new Float32Array(1 * 3 * size);
276
+
277
+  for (let i = 0; i < size; i += 1) {
278
+    const pixelIndex = i * 4;
279
+    const r = data[pixelIndex] / 255;
280
+    const g = data[pixelIndex + 1] / 255;
281
+    const b = data[pixelIndex + 2] / 255;
282
+
283
+    floatData[i] = r;
284
+    floatData[size + i] = g;
285
+    floatData[size * 2 + i] = b;
286
+  }
287
+
288
+  return floatData;
289
+}
290
+
291
+function base64ToUint8Array(base64: string) {
292
+  const cleaned = base64.replace(/[^A-Za-z0-9+/=]/g, '');
293
+  const bytes: number[] = [];
294
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
295
+
296
+  let i = 0;
297
+  while (i < cleaned.length) {
298
+    const enc1 = decodeBase64Char(cleaned.charAt(i++), chars);
299
+    const enc2 = decodeBase64Char(cleaned.charAt(i++), chars);
300
+    const enc3 = decodeBase64Char(cleaned.charAt(i++), chars);
301
+    const enc4 = decodeBase64Char(cleaned.charAt(i++), chars);
302
+
303
+    const chr1 = (enc1 << 2) | (enc2 >> 4);
304
+    const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
305
+    const chr3 = ((enc3 & 3) << 6) | enc4;
306
+
307
+    bytes.push(chr1);
308
+    if (enc3 !== 64) bytes.push(chr2);
309
+    if (enc4 !== 64) bytes.push(chr3);
310
+  }
311
+
312
+  return new Uint8Array(bytes);
313
+}
314
+
315
+function decodeBase64Char(char: string, chars: string) {
316
+  if (char === '=') return 64;
317
+  const index = chars.indexOf(char);
318
+  if (index === -1) return 64;
319
+  return index;
320
+}
321
+
322
+async function loadLogs() {
323
+  const db = await dbPromise;
324
+  const rows = await db.getAllAsync<InferenceLog>(
325
+    `SELECT mi.id, mi.top_label, mi.top_score, mi.created_at, img.uri
326
+     FROM ml_inferences mi
327
+     LEFT JOIN images img ON img.id = mi.image_id
328
+     ORDER BY mi.created_at DESC
329
+     LIMIT 5;`
330
+  );
331
+  return rows;
332
+}
333
+
334
+async function logInference(params: {
335
+  uri: string;
336
+  top: Prediction[];
337
+  scores: Float32Array;
338
+}) {
339
+  const db = await dbPromise;
340
+  const now = new Date().toISOString();
341
+  const topHit = params.top[0];
342
+  const scoresJson = JSON.stringify(
343
+    params.top.map((item) => ({ index: item.index, label: item.label, score: item.score }))
344
+  );
345
+
346
+  const insert = await db.runAsync(
347
+    'INSERT INTO images (observation_id, uri, thumbnail_uri, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?);',
348
+    null,
349
+    params.uri,
350
+    null,
351
+    224,
352
+    224,
353
+    now
354
+  );
355
+
356
+  await db.runAsync(
357
+    'INSERT INTO ml_inferences (image_id, model_name, model_version, top_label, top_score, scores_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);',
358
+    insert.lastInsertRowId,
359
+    'plantvillage_mnv3s_224',
360
+    '1',
361
+    topHit?.label ?? null,
362
+    topHit?.score ?? null,
363
+    scoresJson,
364
+    now
365
+  );
366
+}
367
+
368
+function humanizeLabel(label: string) {
369
+  const cleaned = label
370
+    .replace(/[_/]+/g, ' ')
371
+    .replace(/\s+/g, ' ')
372
+    .trim();
373
+  return cleaned
374
+    .split(' ')
375
+    .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
376
+    .join(' ');
377
+}
378
+
379
+function formatDate(value: string) {
380
+  try {
381
+    return new Date(value).toLocaleString();
382
+  } catch {
383
+    return value;
384
+  }
385
+}
386
+
387
+const styles = StyleSheet.create({
388
+  headerImage: {
389
+    width: '100%',
390
+    height: '100%',
391
+  },
392
+  hero: {
393
+    borderRadius: 20,
394
+    borderWidth: 1,
395
+    flexDirection: 'row',
396
+    gap: 14,
397
+    padding: 16,
398
+    alignItems: 'center',
399
+    marginBottom: 16,
400
+  },
401
+  heroIcon: {
402
+    borderRadius: 999,
403
+    padding: 10,
404
+  },
405
+  heroText: {
406
+    flex: 1,
407
+    gap: 6,
408
+  },
409
+  heroTitle: {
410
+    fontFamily: Fonts.rounded,
411
+  },
412
+  heroSubtitle: {
413
+    color: '#516458',
414
+  },
415
+  card: {
416
+    backgroundColor: '#FFFFFF',
417
+    borderRadius: 18,
418
+    padding: 16,
419
+    gap: 12,
420
+    marginBottom: 16,
421
+    borderWidth: 1,
422
+    borderColor: '#E3DED6',
423
+  },
424
+  cardHeader: {
425
+    flexDirection: 'row',
426
+    alignItems: 'center',
427
+    gap: 12,
428
+    flexWrap: 'wrap',
429
+  },
430
+  statusPill: {
431
+    backgroundColor: '#E6F0E2',
432
+    borderRadius: 999,
433
+    paddingHorizontal: 10,
434
+    paddingVertical: 4,
435
+  },
436
+  statusText: {
437
+    fontSize: 12,
438
+    color: '#3D5F4A',
439
+  },
440
+  previewWrap: {
441
+    borderRadius: 16,
442
+    overflow: 'hidden',
443
+    backgroundColor: '#F3F0EA',
444
+    borderWidth: 1,
445
+    borderColor: '#E1DBD1',
446
+  },
447
+  preview: {
448
+    height: 220,
449
+    width: '100%',
450
+  },
451
+  previewPlaceholder: {
452
+    height: 200,
453
+    alignItems: 'center',
454
+    justifyContent: 'center',
455
+    gap: 8,
456
+  },
457
+  placeholderText: {
458
+    color: '#6B736D',
459
+  },
460
+  inlineHint: {
461
+    color: '#6B736D',
462
+    fontSize: 13,
463
+  },
464
+  actionRow: {
465
+    flexDirection: 'row',
466
+    flexWrap: 'wrap',
467
+    gap: 12,
468
+  },
469
+  results: {
470
+    gap: 12,
471
+  },
472
+  logList: {
473
+    gap: 10,
474
+  },
475
+  logRow: {
476
+    flexDirection: 'row',
477
+    alignItems: 'center',
478
+    gap: 12,
479
+    padding: 10,
480
+    borderRadius: 12,
481
+    backgroundColor: '#F7F4EE',
482
+  },
483
+  logThumb: {
484
+    width: 48,
485
+    height: 48,
486
+    borderRadius: 10,
487
+    backgroundColor: '#ECE7DE',
488
+    alignItems: 'center',
489
+    justifyContent: 'center',
490
+    overflow: 'hidden',
491
+  },
492
+  logImage: {
493
+    width: '100%',
494
+    height: '100%',
495
+  },
496
+  logMeta: {
497
+    flex: 1,
498
+    gap: 2,
499
+  },
500
+  logLabel: {
501
+    fontWeight: '600',
502
+  },
503
+  logSub: {
504
+    fontSize: 12,
505
+    color: '#6B736D',
506
+  },
507
+  logScore: {
508
+    fontWeight: '600',
509
+    color: '#3D5F4A',
510
+  },
511
+  predRow: {
512
+    gap: 6,
513
+  },
514
+  predLabelRow: {
515
+    flexDirection: 'row',
516
+    justifyContent: 'space-between',
517
+    alignItems: 'center',
518
+    gap: 12,
519
+  },
520
+  predLabel: {
521
+    flex: 1,
522
+  },
523
+  predPercent: {
524
+    color: '#3D5F4A',
525
+  },
526
+  barTrack: {
527
+    height: 8,
528
+    borderRadius: 999,
529
+    backgroundColor: '#E7E1D7',
530
+    overflow: 'hidden',
531
+  },
532
+  barFill: {
533
+    height: 8,
534
+    borderRadius: 999,
535
+    backgroundColor: '#4B7B57',
536
+  },
537
+});

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1318 - 0
app/(tabs)/setup.tsx


+ 107 - 0
app/_layout.tsx

@@ -0,0 +1,107 @@
1
+import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
2
+import { Stack } from 'expo-router';
3
+import { StatusBar } from 'expo-status-bar';
4
+import 'react-native-reanimated';
5
+
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { LocalizationProvider, useTranslation } from '@/localization/i18n';
8
+
9
+export const unstable_settings = {
10
+  anchor: '(tabs)',
11
+};
12
+
13
+export default function RootLayout() {
14
+  const colorScheme = useColorScheme();
15
+
16
+  return (
17
+    <LocalizationProvider>
18
+      <RootNavigator colorScheme={colorScheme} />
19
+    </LocalizationProvider>
20
+  );
21
+}
22
+
23
+function RootNavigator({ colorScheme }: { colorScheme: string | null }) {
24
+  const { t } = useTranslation();
25
+
26
+  return (
27
+    <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
28
+      <Stack>
29
+        <Stack.Screen name="(tabs)" options={{ headerShown: false, title: '' }} />
30
+        <Stack.Screen
31
+          name="fields"
32
+          options={{
33
+            headerShown: true,
34
+            title: t('fields.title'),
35
+            headerBackTitleVisible: false,
36
+            headerBackTitle: '',
37
+          }}
38
+        />
39
+        <Stack.Screen
40
+          name="tasks"
41
+          options={{
42
+            headerShown: true,
43
+            title: t('tasks.title'),
44
+            headerBackTitleVisible: false,
45
+            headerBackTitle: '',
46
+          }}
47
+        />
48
+        <Stack.Screen
49
+          name="task-history"
50
+          options={{
51
+            headerShown: true,
52
+            title: t('tasks.historyTitle'),
53
+            headerBackTitleVisible: false,
54
+            headerBackTitle: '',
55
+          }}
56
+        />
57
+        <Stack.Screen
58
+          name="observations"
59
+          options={{
60
+            headerShown: true,
61
+            title: t('observations.title'),
62
+            headerBackTitleVisible: false,
63
+            headerBackTitle: '',
64
+          }}
65
+        />
66
+        <Stack.Screen
67
+          name="crops"
68
+          options={{
69
+            headerShown: true,
70
+            title: t('crops.title'),
71
+            headerBackTitleVisible: false,
72
+            headerBackTitle: '',
73
+          }}
74
+        />
75
+        <Stack.Screen
76
+          name="harvests"
77
+          options={{
78
+            headerShown: true,
79
+            title: t('harvests.title'),
80
+            headerBackTitleVisible: false,
81
+            headerBackTitle: '',
82
+          }}
83
+        />
84
+        <Stack.Screen
85
+          name="sales"
86
+          options={{
87
+            headerShown: true,
88
+            title: t('sales.title'),
89
+            headerBackTitleVisible: false,
90
+            headerBackTitle: '',
91
+          }}
92
+        />
93
+        <Stack.Screen
94
+          name="costs"
95
+          options={{
96
+            headerShown: true,
97
+            title: t('costs.title'),
98
+            headerBackTitleVisible: false,
99
+            headerBackTitle: '',
100
+          }}
101
+        />
102
+        <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
103
+      </Stack>
104
+      <StatusBar style="auto" />
105
+    </ThemeProvider>
106
+  );
107
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1060 - 0
app/costs.tsx


+ 946 - 0
app/crops.tsx

@@ -0,0 +1,946 @@
1
+import { useEffect, useMemo, useState } from 'react';
2
+import {
3
+  ActivityIndicator,
4
+  Alert,
5
+  FlatList,
6
+  Image,
7
+  KeyboardAvoidingView,
8
+  Modal,
9
+  Pressable,
10
+  StyleSheet,
11
+  TextInput,
12
+  View,
13
+  Platform,
14
+} from 'react-native';
15
+import DateTimePicker from '@react-native-community/datetimepicker';
16
+import * as ImagePicker from 'expo-image-picker';
17
+
18
+import { ThemedText } from '@/components/themed-text';
19
+import { ThemedView } from '@/components/themed-view';
20
+import { IconSymbol } from '@/components/ui/icon-symbol';
21
+import { Colors, Fonts } from '@/constants/theme';
22
+import { useTranslation } from '@/localization/i18n';
23
+import { dbPromise, initCoreTables } from '@/services/db';
24
+import { useColorScheme } from '@/hooks/use-color-scheme';
25
+import { ThemedButton } from '@/components/themed-button';
26
+import { IconButton } from '@/components/icon-button';
27
+
28
+type FieldRow = {
29
+  id: number;
30
+  name: string | null;
31
+};
32
+
33
+type CropRow = {
34
+  id: number;
35
+  field_id: number | null;
36
+  crop_name: string | null;
37
+  variety: string | null;
38
+  planting_date: string | null;
39
+  expected_harvest_date: string | null;
40
+  photo_uri: string | null;
41
+  field_name: string | null;
42
+};
43
+
44
+export default function CropsScreen() {
45
+  const { t } = useTranslation();
46
+  const theme = useColorScheme() ?? 'light';
47
+  const palette = Colors[theme];
48
+  const pageSize = 12;
49
+  const [crops, setCrops] = useState<CropRow[]>([]);
50
+  const [fields, setFields] = useState<FieldRow[]>([]);
51
+  const [status, setStatus] = useState(t('crops.loading'));
52
+  const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
53
+  const [cropName, setCropName] = useState('');
54
+  const [variety, setVariety] = useState('');
55
+  const [plantingDate, setPlantingDate] = useState('');
56
+  const [harvestDate, setHarvestDate] = useState('');
57
+  const [photoUri, setPhotoUri] = useState<string | null>(null);
58
+  const [showPlantingPicker, setShowPlantingPicker] = useState(false);
59
+  const [showHarvestPicker, setShowHarvestPicker] = useState(false);
60
+  const [newModalOpen, setNewModalOpen] = useState(false);
61
+  const [editModalOpen, setEditModalOpen] = useState(false);
62
+  const [editingId, setEditingId] = useState<number | null>(null);
63
+  const [editFieldId, setEditFieldId] = useState<number | null>(null);
64
+  const [editName, setEditName] = useState('');
65
+  const [editVariety, setEditVariety] = useState('');
66
+  const [editPlantingDate, setEditPlantingDate] = useState('');
67
+  const [editHarvestDate, setEditHarvestDate] = useState('');
68
+  const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
69
+  const [showEditPlantingPicker, setShowEditPlantingPicker] = useState(false);
70
+  const [showEditHarvestPicker, setShowEditHarvestPicker] = useState(false);
71
+  const [fieldModalOpen, setFieldModalOpen] = useState(false);
72
+  const [fieldModalTarget, setFieldModalTarget] = useState<'new' | 'edit'>('new');
73
+  const [newErrors, setNewErrors] = useState<{ field?: string; name?: string }>({});
74
+  const [editErrors, setEditErrors] = useState<{ field?: string; name?: string }>({});
75
+  const [page, setPage] = useState(1);
76
+  const [hasMore, setHasMore] = useState(true);
77
+  const [loadingMore, setLoadingMore] = useState(false);
78
+
79
+  const selectedField = useMemo(
80
+    () => fields.find((item) => item.id === selectedFieldId),
81
+    [fields, selectedFieldId]
82
+  );
83
+  const selectedEditField = useMemo(
84
+    () => fields.find((item) => item.id === editFieldId),
85
+    [fields, editFieldId]
86
+  );
87
+
88
+  useEffect(() => {
89
+    let isActive = true;
90
+
91
+    async function loadData() {
92
+      try {
93
+        await initCoreTables();
94
+        const db = await dbPromise;
95
+        const fieldRows = await db.getAllAsync<FieldRow>(
96
+          'SELECT id, name FROM fields ORDER BY name ASC;'
97
+        );
98
+        if (!isActive) return;
99
+        setFields(fieldRows);
100
+        await fetchCropsPage(1, true, isActive);
101
+      } catch (error) {
102
+        if (isActive) setStatus(`Error: ${String(error)}`);
103
+      }
104
+    }
105
+
106
+    loadData();
107
+    return () => {
108
+      isActive = false;
109
+    };
110
+  }, [t]);
111
+
112
+  async function fetchCropsPage(pageToLoad: number, replace: boolean, isActive = true) {
113
+    try {
114
+      const db = await dbPromise;
115
+      const cropRows = await db.getAllAsync<CropRow>(
116
+        `SELECT c.id, c.field_id, c.crop_name, c.variety, c.planting_date, c.expected_harvest_date, c.photo_uri,
117
+                f.name as field_name
118
+         FROM crops c
119
+         LEFT JOIN fields f ON f.id = c.field_id
120
+         ORDER BY c.id DESC
121
+         LIMIT ? OFFSET ?;`,
122
+        pageSize,
123
+        (pageToLoad - 1) * pageSize
124
+      );
125
+      if (!isActive) return;
126
+      setCrops((prev) => (replace ? cropRows : [...prev, ...cropRows]));
127
+      setHasMore(cropRows.length === pageSize);
128
+      setPage(pageToLoad);
129
+      if (replace) {
130
+        setStatus(cropRows.length === 0 ? t('crops.empty') : '');
131
+      }
132
+    } catch (error) {
133
+      if (isActive) setStatus(`Error: ${String(error)}`);
134
+    } finally {
135
+      if (isActive) setLoadingMore(false);
136
+    }
137
+  }
138
+
139
+  async function handleLoadMore() {
140
+    if (loadingMore || !hasMore) return;
141
+    setLoadingMore(true);
142
+    const nextPage = page + 1;
143
+    await fetchCropsPage(nextPage, false);
144
+  }
145
+
146
+  async function handleSave() {
147
+    const trimmedName = cropName.trim();
148
+    const nextErrors: { field?: string; name?: string } = {};
149
+    if (!selectedFieldId) {
150
+      nextErrors.field = t('crops.fieldRequired');
151
+    }
152
+    if (!trimmedName) {
153
+      nextErrors.name = t('crops.nameRequired');
154
+    }
155
+    setNewErrors(nextErrors);
156
+    if (Object.keys(nextErrors).length > 0) {
157
+      setStatus(nextErrors.field ?? nextErrors.name ?? t('crops.fieldRequired'));
158
+      return false;
159
+    }
160
+    try {
161
+      const db = await dbPromise;
162
+      await db.runAsync(
163
+        'INSERT INTO crops (field_id, crop_name, variety, planting_date, expected_harvest_date, photo_uri) VALUES (?, ?, ?, ?, ?, ?);',
164
+        selectedFieldId,
165
+        trimmedName,
166
+        variety.trim() || null,
167
+        plantingDate.trim() || null,
168
+        harvestDate.trim() || null,
169
+        photoUri
170
+      );
171
+      setCropName('');
172
+      setVariety('');
173
+      setPlantingDate('');
174
+      setHarvestDate('');
175
+      setPhotoUri(null);
176
+      setNewErrors({});
177
+      await fetchCropsPage(1, true);
178
+      setStatus(t('crops.saved'));
179
+      return true;
180
+    } catch (error) {
181
+      setStatus(`Error: ${String(error)}`);
182
+      return false;
183
+    }
184
+  }
185
+
186
+  async function handleDelete(id: number) {
187
+    try {
188
+      const db = await dbPromise;
189
+      await db.runAsync('DELETE FROM crops WHERE id = ?;', id);
190
+      await fetchCropsPage(1, true);
191
+    } catch (error) {
192
+      setStatus(`Error: ${String(error)}`);
193
+    }
194
+  }
195
+
196
+  function confirmDelete(id: number) {
197
+    Alert.alert(
198
+      t('crops.deleteTitle'),
199
+      t('crops.deleteMessage'),
200
+      [
201
+        { text: t('crops.cancel'), style: 'cancel' },
202
+        { text: t('crops.delete'), style: 'destructive', onPress: () => handleDelete(id) },
203
+      ]
204
+    );
205
+  }
206
+
207
+  function startEdit(crop: CropRow) {
208
+    setEditingId(crop.id);
209
+    setEditFieldId(crop.field_id ?? null);
210
+    setEditName(crop.crop_name ?? '');
211
+    setEditVariety(crop.variety ?? '');
212
+    setEditPlantingDate(crop.planting_date ?? '');
213
+    setEditHarvestDate(crop.expected_harvest_date ?? '');
214
+    setEditPhotoUri(crop.photo_uri ?? null);
215
+    setEditErrors({});
216
+    setEditModalOpen(true);
217
+  }
218
+
219
+  function cancelEdit() {
220
+    setEditingId(null);
221
+    setEditFieldId(null);
222
+    setEditName('');
223
+    setEditVariety('');
224
+    setEditPlantingDate('');
225
+    setEditHarvestDate('');
226
+    setEditPhotoUri(null);
227
+    setEditErrors({});
228
+    setEditModalOpen(false);
229
+  }
230
+
231
+  async function handleUpdate() {
232
+    if (!editingId) return;
233
+    const trimmedName = editName.trim();
234
+    const nextErrors: { field?: string; name?: string } = {};
235
+    if (!editFieldId) {
236
+      nextErrors.field = t('crops.fieldRequired');
237
+    }
238
+    if (!trimmedName) {
239
+      nextErrors.name = t('crops.nameRequired');
240
+    }
241
+    setEditErrors(nextErrors);
242
+    if (Object.keys(nextErrors).length > 0) {
243
+      setStatus(nextErrors.field ?? nextErrors.name ?? t('crops.fieldRequired'));
244
+      return;
245
+    }
246
+    try {
247
+      const db = await dbPromise;
248
+      await db.runAsync(
249
+        'UPDATE crops SET field_id = ?, crop_name = ?, variety = ?, planting_date = ?, expected_harvest_date = ?, photo_uri = ? WHERE id = ?;',
250
+        editFieldId,
251
+        trimmedName,
252
+        editVariety.trim() || null,
253
+        editPlantingDate.trim() || null,
254
+        editHarvestDate.trim() || null,
255
+        editPhotoUri,
256
+        editingId
257
+      );
258
+      setEditModalOpen(false);
259
+      setEditingId(null);
260
+      setEditErrors({});
261
+      await fetchCropsPage(1, true);
262
+      setStatus(t('crops.saved'));
263
+    } catch (error) {
264
+      setStatus(`Error: ${String(error)}`);
265
+    }
266
+  }
267
+
268
+  const inputStyle = [
269
+    styles.input,
270
+    { borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
271
+  ];
272
+
273
+  return (
274
+    <>
275
+      <FlatList
276
+        data={crops}
277
+        keyExtractor={(item) => String(item.id)}
278
+        renderItem={({ item }) => (
279
+          <Pressable onPress={() => startEdit(item)}>
280
+          <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
281
+              <ThemedText type="subtitle">{item.crop_name || t('crops.untitled')}</ThemedText>
282
+              <ThemedText style={styles.meta}>
283
+                {item.field_name || t('crops.noField')}
284
+              </ThemedText>
285
+            {item.variety ? <ThemedText>{item.variety}</ThemedText> : null}
286
+            {item.photo_uri ? (
287
+              <Image source={{ uri: item.photo_uri }} style={styles.photoPreview} resizeMode="cover" />
288
+            ) : null}
289
+            {item.planting_date ? (
290
+                <ThemedText style={styles.meta}>
291
+                  {t('crops.plantingLabel')} {item.planting_date}
292
+                </ThemedText>
293
+              ) : null}
294
+              {item.expected_harvest_date ? (
295
+                <ThemedText style={styles.meta}>
296
+                  {t('crops.harvestLabel')} {item.expected_harvest_date}
297
+                </ThemedText>
298
+              ) : null}
299
+              <View style={styles.buttonRow}>
300
+                <IconButton
301
+                  name="trash"
302
+                  onPress={() => confirmDelete(item.id)}
303
+                  accessibilityLabel={t('crops.delete')}
304
+                  variant="danger"
305
+                />
306
+              </View>
307
+            </ThemedView>
308
+          </Pressable>
309
+        )}
310
+        ItemSeparatorComponent={() => <View style={styles.separator} />}
311
+        onEndReached={handleLoadMore}
312
+        onEndReachedThreshold={0.4}
313
+        ListHeaderComponent={
314
+          <View>
315
+            <ThemedView style={styles.hero}>
316
+              <Image source={require('@/assets/images/crops.jpg')} style={styles.heroImage} />
317
+            </ThemedView>
318
+            <ThemedView style={styles.titleContainer}>
319
+              <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
320
+                {t('crops.title')}
321
+              </ThemedText>
322
+            </ThemedView>
323
+
324
+            {status ? (
325
+              <ThemedView style={styles.section}>
326
+                <ThemedText>{status}</ThemedText>
327
+              </ThemedView>
328
+            ) : null}
329
+
330
+            <ThemedView style={styles.section}>
331
+              <Pressable
332
+                style={styles.newButton}
333
+                onPress={() => {
334
+                  setNewErrors({});
335
+                  setNewModalOpen(true);
336
+                }}>
337
+                <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
338
+                <ThemedText style={styles.newButtonText}>{t('crops.new')}</ThemedText>
339
+              </Pressable>
340
+            </ThemedView>
341
+          </View>
342
+        }
343
+        ListFooterComponent={
344
+          <View style={styles.footer}>
345
+            {loadingMore ? <ActivityIndicator /> : null}
346
+          </View>
347
+        }
348
+      />
349
+
350
+      <Modal transparent visible={fieldModalOpen} animationType="fade">
351
+        <Pressable
352
+          style={styles.modalOverlay}
353
+          onPress={() => setFieldModalOpen(false)}>
354
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
355
+            <ThemedText type="subtitle">{t('crops.selectField')}</ThemedText>
356
+            <FlatList
357
+              data={fields}
358
+              keyExtractor={(item) => String(item.id)}
359
+              renderItem={({ item }) => (
360
+                <Pressable
361
+                  onPress={() => {
362
+                    if (fieldModalTarget === 'new') {
363
+                      setSelectedFieldId(item.id);
364
+                      setNewErrors((prev) => ({ ...prev, field: undefined }));
365
+                      setNewModalOpen(true);
366
+                    } else {
367
+                      setEditFieldId(item.id);
368
+                      setEditErrors((prev) => ({ ...prev, field: undefined }));
369
+                      setEditModalOpen(true);
370
+                    }
371
+                    setFieldModalOpen(false);
372
+                  }}
373
+                  style={styles.modalItem}>
374
+                  <ThemedText>{item.name || t('crops.untitled')}</ThemedText>
375
+                </Pressable>
376
+              )}
377
+            />
378
+          </View>
379
+        </Pressable>
380
+      </Modal>
381
+
382
+      <Modal
383
+        transparent
384
+        visible={newModalOpen}
385
+        animationType="slide"
386
+        onRequestClose={() => setNewModalOpen(false)}>
387
+        <View style={styles.sheetOverlay}>
388
+          <Pressable style={styles.sheetBackdrop} onPress={() => setNewModalOpen(false)} />
389
+          <KeyboardAvoidingView
390
+            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
391
+            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
392
+            style={styles.keyboardAvoid}>
393
+            <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
394
+              <FlatList
395
+                data={[{ key: 'new' }]}
396
+                keyExtractor={(item) => item.key}
397
+                contentContainerStyle={styles.sheetListContent}
398
+                renderItem={() => (
399
+                  <View style={styles.sheetContent}>
400
+                  <ThemedText type="subtitle">{t('crops.new')}</ThemedText>
401
+                  <ThemedText>
402
+                    {t('crops.field')}
403
+                    <ThemedText style={styles.requiredMark}> *</ThemedText>
404
+                  </ThemedText>
405
+                  <ThemedButton
406
+                    title={selectedField?.name || t('crops.selectField')}
407
+                    onPress={() => {
408
+                      setFieldModalTarget('new');
409
+                      setNewModalOpen(false);
410
+                      setFieldModalOpen(true);
411
+                    }}
412
+                    variant="secondary"
413
+                  />
414
+                  {newErrors.field ? (
415
+                    <ThemedText style={styles.errorText}>{newErrors.field}</ThemedText>
416
+                  ) : null}
417
+                  <ThemedText>
418
+                    {t('crops.name')}
419
+                    <ThemedText style={styles.requiredMark}> *</ThemedText>
420
+                  </ThemedText>
421
+                  <TextInput
422
+                    value={cropName}
423
+                    onChangeText={(value) => {
424
+                      setCropName(value);
425
+                      if (newErrors.name) {
426
+                        setNewErrors((prev) => ({ ...prev, name: undefined }));
427
+                      }
428
+                    }}
429
+                    placeholder={t('crops.namePlaceholder')}
430
+                    style={inputStyle}
431
+                  />
432
+                  {newErrors.name ? (
433
+                    <ThemedText style={styles.errorText}>{newErrors.name}</ThemedText>
434
+                  ) : null}
435
+                  <ThemedText>{t('crops.variety')}</ThemedText>
436
+                  <TextInput
437
+                    value={variety}
438
+                    onChangeText={setVariety}
439
+                    placeholder={t('crops.varietyPlaceholder')}
440
+                    style={inputStyle}
441
+                  />
442
+                  <ThemedText>{t('crops.photo')}</ThemedText>
443
+                  {photoUri ? (
444
+                    <Image source={{ uri: photoUri }} style={styles.photoPreview} resizeMode="cover" />
445
+                  ) : (
446
+                    <ThemedText style={styles.photoPlaceholder}>{t('crops.noPhoto')}</ThemedText>
447
+                  )}
448
+                  <View style={styles.photoRow}>
449
+                    <ThemedButton
450
+                      title={t('crops.pickPhoto')}
451
+                      onPress={() => handlePickPhoto(setPhotoUri)}
452
+                      variant="secondary"
453
+                    />
454
+                    <ThemedButton
455
+                      title={t('crops.takePhoto')}
456
+                      onPress={() =>
457
+                        handleTakePhoto(setPhotoUri, (code) =>
458
+                          setStatus(
459
+                            code === 'cameraDenied' ? t('tasks.cameraDenied') : t('tasks.cameraError')
460
+                          )
461
+                        )
462
+                      }
463
+                      variant="secondary"
464
+                    />
465
+                  </View>
466
+                  <ThemedText>{t('crops.planting')}</ThemedText>
467
+                  <Pressable onPress={() => setShowPlantingPicker(true)} style={styles.dateInput}>
468
+                    <ThemedText style={styles.dateValue}>
469
+                      {plantingDate ? formatDateLabel(plantingDate) : t('crops.plantingPlaceholder')}
470
+                    </ThemedText>
471
+                  </Pressable>
472
+            {showPlantingPicker ? (
473
+              <>
474
+                {Platform.OS === 'ios' ? (
475
+                  <View style={styles.pickerRow}>
476
+                    <ThemedButton
477
+                      title={t('crops.today')}
478
+                      onPress={() => {
479
+                        setPlantingDate(toDateOnly(new Date()));
480
+                        setShowPlantingPicker(false);
481
+                      }}
482
+                      variant="secondary"
483
+                    />
484
+                    <ThemedButton
485
+                      title={t('crops.done')}
486
+                      onPress={() => setShowPlantingPicker(false)}
487
+                      variant="secondary"
488
+                    />
489
+                  </View>
490
+                ) : null}
491
+                <DateTimePicker
492
+                  value={plantingDate ? new Date(plantingDate) : new Date()}
493
+                  mode="date"
494
+                  display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
495
+                  onChange={(event, date) => {
496
+                    if (date) setPlantingDate(toDateOnly(date));
497
+                    if (Platform.OS !== 'ios') {
498
+                      setShowPlantingPicker(false);
499
+                    }
500
+                  }}
501
+                />
502
+              </>
503
+            ) : null}
504
+                  <ThemedText>{t('crops.harvest')}</ThemedText>
505
+                  <Pressable onPress={() => setShowHarvestPicker(true)} style={styles.dateInput}>
506
+                    <ThemedText style={styles.dateValue}>
507
+                      {harvestDate ? formatDateLabel(harvestDate) : t('crops.harvestPlaceholder')}
508
+                    </ThemedText>
509
+                  </Pressable>
510
+            {showHarvestPicker ? (
511
+              <>
512
+                {Platform.OS === 'ios' ? (
513
+                  <View style={styles.pickerRow}>
514
+                    <ThemedButton
515
+                      title={t('crops.today')}
516
+                      onPress={() => {
517
+                        setHarvestDate(toDateOnly(new Date()));
518
+                        setShowHarvestPicker(false);
519
+                      }}
520
+                      variant="secondary"
521
+                    />
522
+                    <ThemedButton
523
+                      title={t('crops.done')}
524
+                      onPress={() => setShowHarvestPicker(false)}
525
+                      variant="secondary"
526
+                    />
527
+                  </View>
528
+                ) : null}
529
+                <DateTimePicker
530
+                  value={harvestDate ? new Date(harvestDate) : new Date()}
531
+                  mode="date"
532
+                  display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
533
+                  onChange={(event, date) => {
534
+                    if (date) setHarvestDate(toDateOnly(date));
535
+                    if (Platform.OS !== 'ios') {
536
+                      setShowHarvestPicker(false);
537
+                    }
538
+                  }}
539
+                />
540
+              </>
541
+            ) : null}
542
+                  <View style={styles.modalActions}>
543
+                    <ThemedButton
544
+                      title={t('crops.cancel')}
545
+                      onPress={() => setNewModalOpen(false)}
546
+                      variant="secondary"
547
+                    />
548
+                    <ThemedButton
549
+                      title={t('crops.save')}
550
+                      onPress={async () => {
551
+                        const ok = await handleSave();
552
+                        if (ok) setNewModalOpen(false);
553
+                      }}
554
+                    />
555
+                  </View>
556
+                  </View>
557
+                )}
558
+                ListFooterComponent={<View style={styles.sheetFooter} />}
559
+              />
560
+            </View>
561
+          </KeyboardAvoidingView>
562
+        </View>
563
+      </Modal>
564
+
565
+      <Modal transparent visible={editModalOpen} animationType="slide">
566
+        <View style={styles.sheetOverlay}>
567
+          <Pressable style={styles.sheetBackdrop} onPress={cancelEdit} />
568
+          <KeyboardAvoidingView
569
+            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
570
+            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
571
+            style={styles.keyboardAvoid}>
572
+            <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
573
+              <FlatList
574
+                data={[{ key: 'edit' }]}
575
+                keyExtractor={(item) => item.key}
576
+                contentContainerStyle={styles.sheetListContent}
577
+                renderItem={() => (
578
+                  <View style={styles.sheetContent}>
579
+                  <ThemedText type="subtitle">{t('crops.edit')}</ThemedText>
580
+                  <ThemedText>
581
+                    {t('crops.field')}
582
+                    <ThemedText style={styles.requiredMark}> *</ThemedText>
583
+                  </ThemedText>
584
+                  <ThemedButton
585
+                    title={selectedEditField?.name || t('crops.selectField')}
586
+                    onPress={() => {
587
+                      setFieldModalTarget('edit');
588
+                      setEditModalOpen(false);
589
+                      setFieldModalOpen(true);
590
+                    }}
591
+                    variant="secondary"
592
+                  />
593
+                  {editErrors.field ? (
594
+                    <ThemedText style={styles.errorText}>{editErrors.field}</ThemedText>
595
+                  ) : null}
596
+                  <ThemedText>
597
+                    {t('crops.name')}
598
+                    <ThemedText style={styles.requiredMark}> *</ThemedText>
599
+                  </ThemedText>
600
+                  <TextInput
601
+                    value={editName}
602
+                    onChangeText={(value) => {
603
+                      setEditName(value);
604
+                      if (editErrors.name) {
605
+                        setEditErrors((prev) => ({ ...prev, name: undefined }));
606
+                      }
607
+                    }}
608
+                    placeholder={t('crops.namePlaceholder')}
609
+                    style={inputStyle}
610
+                  />
611
+                  {editErrors.name ? (
612
+                    <ThemedText style={styles.errorText}>{editErrors.name}</ThemedText>
613
+                  ) : null}
614
+                  <ThemedText>{t('crops.variety')}</ThemedText>
615
+                  <TextInput
616
+                    value={editVariety}
617
+                    onChangeText={setEditVariety}
618
+                    placeholder={t('crops.varietyPlaceholder')}
619
+                    style={inputStyle}
620
+                  />
621
+                  <ThemedText>{t('crops.photo')}</ThemedText>
622
+                  {editPhotoUri ? (
623
+                    <Image
624
+                      source={{ uri: editPhotoUri }}
625
+                      style={styles.photoPreview}
626
+                      resizeMode="cover"
627
+                    />
628
+                  ) : (
629
+                    <ThemedText style={styles.photoPlaceholder}>{t('crops.noPhoto')}</ThemedText>
630
+                  )}
631
+                  <View style={styles.photoRow}>
632
+                    <ThemedButton
633
+                      title={t('crops.pickPhoto')}
634
+                      onPress={() => handlePickPhoto(setEditPhotoUri)}
635
+                      variant="secondary"
636
+                    />
637
+                    <ThemedButton
638
+                      title={t('crops.takePhoto')}
639
+                      onPress={() =>
640
+                        handleTakePhoto(setEditPhotoUri, (code) =>
641
+                          setStatus(
642
+                            code === 'cameraDenied' ? t('tasks.cameraDenied') : t('tasks.cameraError')
643
+                          )
644
+                        )
645
+                      }
646
+                      variant="secondary"
647
+                    />
648
+                  </View>
649
+                  <ThemedText>{t('crops.planting')}</ThemedText>
650
+                  <Pressable onPress={() => setShowEditPlantingPicker(true)} style={styles.dateInput}>
651
+                    <ThemedText style={styles.dateValue}>
652
+                      {editPlantingDate
653
+                        ? formatDateLabel(editPlantingDate)
654
+                        : t('crops.plantingPlaceholder')}
655
+                    </ThemedText>
656
+                  </Pressable>
657
+            {showEditPlantingPicker ? (
658
+              <>
659
+                {Platform.OS === 'ios' ? (
660
+                  <View style={styles.pickerRow}>
661
+                    <ThemedButton
662
+                      title={t('crops.today')}
663
+                      onPress={() => {
664
+                        setEditPlantingDate(toDateOnly(new Date()));
665
+                        setShowEditPlantingPicker(false);
666
+                      }}
667
+                      variant="secondary"
668
+                    />
669
+                    <ThemedButton
670
+                      title={t('crops.done')}
671
+                      onPress={() => setShowEditPlantingPicker(false)}
672
+                      variant="secondary"
673
+                    />
674
+                  </View>
675
+                ) : null}
676
+                <DateTimePicker
677
+                  value={editPlantingDate ? new Date(editPlantingDate) : new Date()}
678
+                  mode="date"
679
+                  display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
680
+                  onChange={(event, date) => {
681
+                    if (date) setEditPlantingDate(toDateOnly(date));
682
+                    if (Platform.OS !== 'ios') {
683
+                      setShowEditPlantingPicker(false);
684
+                    }
685
+                  }}
686
+                />
687
+              </>
688
+            ) : null}
689
+                  <ThemedText>{t('crops.harvest')}</ThemedText>
690
+                  <Pressable onPress={() => setShowEditHarvestPicker(true)} style={styles.dateInput}>
691
+                    <ThemedText style={styles.dateValue}>
692
+                      {editHarvestDate
693
+                        ? formatDateLabel(editHarvestDate)
694
+                        : t('crops.harvestPlaceholder')}
695
+                    </ThemedText>
696
+                  </Pressable>
697
+            {showEditHarvestPicker ? (
698
+              <>
699
+                {Platform.OS === 'ios' ? (
700
+                  <View style={styles.pickerRow}>
701
+                    <ThemedButton
702
+                      title={t('crops.today')}
703
+                      onPress={() => {
704
+                        setEditHarvestDate(toDateOnly(new Date()));
705
+                        setShowEditHarvestPicker(false);
706
+                      }}
707
+                      variant="secondary"
708
+                    />
709
+                    <ThemedButton
710
+                      title={t('crops.done')}
711
+                      onPress={() => setShowEditHarvestPicker(false)}
712
+                      variant="secondary"
713
+                    />
714
+                  </View>
715
+                ) : null}
716
+                <DateTimePicker
717
+                  value={editHarvestDate ? new Date(editHarvestDate) : new Date()}
718
+                  mode="date"
719
+                  display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
720
+                  onChange={(event, date) => {
721
+                    if (date) setEditHarvestDate(toDateOnly(date));
722
+                    if (Platform.OS !== 'ios') {
723
+                      setShowEditHarvestPicker(false);
724
+                    }
725
+                  }}
726
+                />
727
+              </>
728
+            ) : null}
729
+                  <View style={styles.modalActions}>
730
+                    <ThemedButton title={t('crops.cancel')} onPress={cancelEdit} variant="secondary" />
731
+                    <ThemedButton title={t('crops.update')} onPress={handleUpdate} />
732
+                  </View>
733
+                  </View>
734
+                )}
735
+                ListFooterComponent={<View style={styles.sheetFooter} />}
736
+              />
737
+            </View>
738
+          </KeyboardAvoidingView>
739
+        </View>
740
+      </Modal>
741
+
742
+    </>
743
+  );
744
+}
745
+
746
+function toDateOnly(date: Date) {
747
+  return date.toISOString().slice(0, 10);
748
+}
749
+
750
+function formatDateLabel(value: string) {
751
+  try {
752
+    return new Date(value).toLocaleDateString();
753
+  } catch {
754
+    return value;
755
+  }
756
+}
757
+
758
+async function handlePickPhoto(setter: (value: string | null) => void) {
759
+  const result = await ImagePicker.launchImageLibraryAsync({
760
+    mediaTypes: getImageMediaTypes(),
761
+    quality: 1,
762
+  });
763
+  if (result.canceled) return;
764
+  const asset = result.assets[0];
765
+  setter(asset.uri);
766
+}
767
+
768
+async function handleTakePhoto(
769
+  setter: (value: string | null) => void,
770
+  onError?: (msg: string) => void
771
+) {
772
+  try {
773
+    const permission = await ImagePicker.requestCameraPermissionsAsync();
774
+    if (!permission.granted) {
775
+      onError?.('cameraDenied');
776
+      return;
777
+    }
778
+    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
779
+    if (result.canceled) return;
780
+    const asset = result.assets[0];
781
+    setter(asset.uri);
782
+  } catch {
783
+    onError?.('cameraError');
784
+  }
785
+}
786
+
787
+function getImageMediaTypes() {
788
+  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
789
+    .MediaType;
790
+  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
791
+}
792
+
793
+const styles = StyleSheet.create({
794
+  hero: {
795
+    backgroundColor: '#E8E6DA',
796
+    aspectRatio: 16 / 9,
797
+    width: '100%',
798
+  },
799
+  heroImage: {
800
+    width: '100%',
801
+    height: '100%',
802
+  },
803
+  titleContainer: {
804
+    gap: 8,
805
+    paddingHorizontal: 16,
806
+    paddingVertical: 12,
807
+  },
808
+  section: {
809
+    gap: 8,
810
+    marginBottom: 16,
811
+    paddingHorizontal: 16,
812
+  },
813
+  card: {
814
+    borderRadius: 12,
815
+    borderWidth: 1,
816
+    borderColor: '#C6C6C6',
817
+    padding: 12,
818
+    marginHorizontal: 16,
819
+    gap: 6,
820
+  },
821
+  meta: {
822
+    opacity: 0.7,
823
+  },
824
+  input: {
825
+    borderRadius: 10,
826
+    borderWidth: 1,
827
+    borderColor: '#B9B9B9',
828
+    paddingHorizontal: 12,
829
+    paddingVertical: 10,
830
+    fontSize: 15,
831
+  },
832
+  requiredMark: {
833
+    color: '#C0392B',
834
+    fontWeight: '700',
835
+  },
836
+  errorText: {
837
+    color: '#C0392B',
838
+    fontSize: 12,
839
+  },
840
+  photoPreview: {
841
+    height: 160,
842
+    width: '100%',
843
+    borderRadius: 12,
844
+  },
845
+  photoRow: {
846
+    flexDirection: 'row',
847
+    gap: 8,
848
+  },
849
+  photoPlaceholder: {
850
+    opacity: 0.6,
851
+  },
852
+  dateInput: {
853
+    borderRadius: 10,
854
+    borderWidth: 1,
855
+    borderColor: '#B9B9B9',
856
+    paddingHorizontal: 12,
857
+    paddingVertical: 10,
858
+  },
859
+  dateValue: {
860
+    fontSize: 15,
861
+    opacity: 0.9,
862
+  },
863
+  pickerRow: {
864
+    flexDirection: 'row',
865
+    gap: 8,
866
+  },
867
+  buttonRow: {
868
+    alignSelf: 'flex-start',
869
+  },
870
+  separator: {
871
+    height: 12,
872
+  },
873
+  footer: {
874
+    height: 24,
875
+  },
876
+  modalOverlay: {
877
+    flex: 1,
878
+    backgroundColor: 'rgba(0,0,0,0.3)',
879
+    justifyContent: 'center',
880
+    padding: 16,
881
+  },
882
+  modalCard: {
883
+    borderRadius: 12,
884
+    borderWidth: 1,
885
+    borderColor: '#C6C6C6',
886
+    padding: 16,
887
+    backgroundColor: '#FFFFFF',
888
+    gap: 8,
889
+    maxHeight: '70%',
890
+  },
891
+  modalActions: {
892
+    flexDirection: 'row',
893
+    justifyContent: 'space-between',
894
+    gap: 12,
895
+  },
896
+  newButton: {
897
+    flexDirection: 'row',
898
+    alignItems: 'center',
899
+    gap: 8,
900
+    borderRadius: 10,
901
+    borderWidth: 1,
902
+    borderColor: '#B9B9B9',
903
+    paddingHorizontal: 12,
904
+    paddingVertical: 10,
905
+    alignSelf: 'flex-start',
906
+  },
907
+  newButtonText: {
908
+    fontSize: 15,
909
+    fontWeight: '600',
910
+  },
911
+  sheetOverlay: {
912
+    flex: 1,
913
+    backgroundColor: 'rgba(0,0,0,0.3)',
914
+    justifyContent: 'flex-end',
915
+  },
916
+  sheetBackdrop: {
917
+    ...StyleSheet.absoluteFillObject,
918
+  },
919
+  sheet: {
920
+    borderTopLeftRadius: 16,
921
+    borderTopRightRadius: 16,
922
+    borderWidth: 1,
923
+    borderColor: '#C6C6C6',
924
+    padding: 16,
925
+    backgroundColor: '#FFFFFF',
926
+    gap: 10,
927
+    maxHeight: '85%',
928
+  },
929
+  sheetContent: {
930
+    gap: 10,
931
+  },
932
+  sheetListContent: {
933
+    paddingBottom: 80,
934
+  },
935
+  sheetFooter: {
936
+    height: 24,
937
+  },
938
+  keyboardAvoid: {
939
+    width: '100%',
940
+    flex: 1,
941
+    justifyContent: 'flex-end',
942
+  },
943
+  modalItem: {
944
+    paddingVertical: 8,
945
+  },
946
+});

+ 721 - 0
app/fields.tsx

@@ -0,0 +1,721 @@
1
+import { useEffect, useState } from 'react';
2
+import {
3
+  ActivityIndicator,
4
+  Alert,
5
+  FlatList,
6
+  Image,
7
+  KeyboardAvoidingView,
8
+  Modal,
9
+  Pressable,
10
+  Platform,
11
+  ScrollView,
12
+  StyleSheet,
13
+  TextInput,
14
+  View,
15
+} from 'react-native';
16
+import * as ImagePicker from 'expo-image-picker';
17
+
18
+import { ThemedText } from '@/components/themed-text';
19
+import { ThemedView } from '@/components/themed-view';
20
+import { IconSymbol } from '@/components/ui/icon-symbol';
21
+import { ThemedButton } from '@/components/themed-button';
22
+import { IconButton } from '@/components/icon-button';
23
+import { Colors, Fonts } from '@/constants/theme';
24
+import { useTranslation } from '@/localization/i18n';
25
+import { dbPromise, initCoreTables } from '@/services/db';
26
+import { useColorScheme } from '@/hooks/use-color-scheme';
27
+
28
+type FieldRow = {
29
+  id: number;
30
+  name: string | null;
31
+  area_ha: number | null;
32
+  notes: string | null;
33
+  photo_uri: string | null;
34
+  created_at: string | null;
35
+  updated_at: string | null;
36
+};
37
+
38
+export default function FieldsScreen() {
39
+  const { t } = useTranslation();
40
+  const theme = useColorScheme() ?? 'light';
41
+  const palette = Colors[theme];
42
+  const pageSize = 12;
43
+  const [fields, setFields] = useState<FieldRow[]>([]);
44
+  const [status, setStatus] = useState(t('fields.loading'));
45
+  const [name, setName] = useState('');
46
+  const [areaHa, setAreaHa] = useState('');
47
+  const [notes, setNotes] = useState('');
48
+  const [photoUri, setPhotoUri] = useState<string | null>(null);
49
+  const [newModalVisible, setNewModalVisible] = useState(false);
50
+  const [editingId, setEditingId] = useState<number | null>(null);
51
+  const [editModalVisible, setEditModalVisible] = useState(false);
52
+  const [editName, setEditName] = useState('');
53
+  const [editAreaHa, setEditAreaHa] = useState('');
54
+  const [editNotes, setEditNotes] = useState('');
55
+  const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
56
+  const [newErrors, setNewErrors] = useState<{ name?: string; area?: string }>({});
57
+  const [editErrors, setEditErrors] = useState<{ name?: string; area?: string }>({});
58
+  const [page, setPage] = useState(1);
59
+  const [hasMore, setHasMore] = useState(true);
60
+  const [loadingMore, setLoadingMore] = useState(false);
61
+
62
+  useEffect(() => {
63
+    let isActive = true;
64
+
65
+    async function loadFields() {
66
+      await fetchFieldsPage(1, true, isActive);
67
+    }
68
+
69
+    loadFields();
70
+    return () => {
71
+      isActive = false;
72
+    };
73
+  }, [t]);
74
+
75
+  async function fetchFieldsPage(pageToLoad: number, replace: boolean, isActive = true) {
76
+    try {
77
+      await initCoreTables();
78
+      const db = await dbPromise;
79
+      const rows = await db.getAllAsync<FieldRow>(
80
+        'SELECT id, name, area_ha, notes, photo_uri, created_at, updated_at FROM fields ORDER BY id DESC LIMIT ? OFFSET ?;',
81
+        pageSize,
82
+        (pageToLoad - 1) * pageSize
83
+      );
84
+      if (!isActive) return;
85
+      setFields((prev) => (replace ? rows : [...prev, ...rows]));
86
+      setHasMore(rows.length === pageSize);
87
+      setPage(pageToLoad);
88
+      if (replace) {
89
+        setStatus(rows.length === 0 ? t('fields.empty') : '');
90
+      }
91
+    } catch (error) {
92
+      if (isActive) setStatus(`Error: ${String(error)}`);
93
+    } finally {
94
+      if (isActive) setLoadingMore(false);
95
+    }
96
+  }
97
+
98
+  async function handleLoadMore() {
99
+    if (loadingMore || !hasMore) return;
100
+    setLoadingMore(true);
101
+    const nextPage = page + 1;
102
+    await fetchFieldsPage(nextPage, false);
103
+  }
104
+
105
+  async function handleSave() {
106
+    const trimmedName = name.trim();
107
+    const area = areaHa.trim() ? Number(areaHa) : null;
108
+    const nextErrors: { name?: string; area?: string } = {};
109
+    if (!trimmedName) {
110
+      nextErrors.name = t('fields.nameRequired');
111
+    }
112
+    if (areaHa.trim() && !Number.isFinite(area)) {
113
+      nextErrors.area = t('fields.areaInvalid');
114
+    }
115
+    setNewErrors(nextErrors);
116
+    if (Object.keys(nextErrors).length > 0) {
117
+      setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
118
+      return false;
119
+    }
120
+    try {
121
+      const db = await dbPromise;
122
+      const now = new Date().toISOString();
123
+      if (editingId) {
124
+        await db.runAsync(
125
+          'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
126
+          trimmedName,
127
+          area,
128
+          notes.trim() || null,
129
+          photoUri,
130
+          now,
131
+          editingId
132
+        );
133
+        setEditingId(null);
134
+      } else {
135
+        await db.runAsync(
136
+          'INSERT INTO fields (name, area_ha, notes, photo_uri, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?);',
137
+          trimmedName,
138
+          area,
139
+          notes.trim() || null,
140
+          photoUri,
141
+          now,
142
+          now
143
+        );
144
+      }
145
+      setName('');
146
+      setAreaHa('');
147
+      setNotes('');
148
+      setPhotoUri(null);
149
+      setNewErrors({});
150
+      await fetchFieldsPage(1, true);
151
+      setStatus(t('fields.saved'));
152
+      return true;
153
+    } catch (error) {
154
+      setStatus(`Error: ${String(error)}`);
155
+      return false;
156
+    }
157
+  }
158
+
159
+  async function handleDelete(id: number) {
160
+    try {
161
+      const db = await dbPromise;
162
+      await db.runAsync('DELETE FROM fields WHERE id = ?;', id);
163
+      await fetchFieldsPage(1, true);
164
+    } catch (error) {
165
+      setStatus(`Error: ${String(error)}`);
166
+    }
167
+  }
168
+
169
+  function confirmDelete(id: number) {
170
+    Alert.alert(
171
+      t('fields.deleteTitle'),
172
+      t('fields.deleteMessage'),
173
+      [
174
+        { text: t('fields.cancel'), style: 'cancel' },
175
+        { text: t('fields.delete'), style: 'destructive', onPress: () => handleDelete(id) },
176
+      ]
177
+    );
178
+  }
179
+
180
+  function startEdit(field: FieldRow) {
181
+    setEditingId(field.id);
182
+    setEditName(field.name ?? '');
183
+    setEditAreaHa(field.area_ha !== null ? String(field.area_ha) : '');
184
+    setEditNotes(field.notes ?? '');
185
+    setEditPhotoUri(field.photo_uri ?? null);
186
+    setEditErrors({});
187
+    setEditModalVisible(true);
188
+    setStatus('');
189
+  }
190
+
191
+  function cancelEdit() {
192
+    setEditingId(null);
193
+    setEditName('');
194
+    setEditAreaHa('');
195
+    setEditNotes('');
196
+    setEditPhotoUri(null);
197
+    setEditErrors({});
198
+    setEditModalVisible(false);
199
+    setStatus('');
200
+  }
201
+
202
+  async function handleUpdate() {
203
+    if (!editingId) return;
204
+    const trimmedName = editName.trim();
205
+    const area = editAreaHa.trim() ? Number(editAreaHa) : null;
206
+    const nextErrors: { name?: string; area?: string } = {};
207
+    if (!trimmedName) {
208
+      nextErrors.name = t('fields.nameRequired');
209
+    }
210
+    if (editAreaHa.trim() && !Number.isFinite(area)) {
211
+      nextErrors.area = t('fields.areaInvalid');
212
+    }
213
+    setEditErrors(nextErrors);
214
+    if (Object.keys(nextErrors).length > 0) {
215
+      setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
216
+      return;
217
+    }
218
+    try {
219
+      const db = await dbPromise;
220
+      const now = new Date().toISOString();
221
+      await db.runAsync(
222
+        'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
223
+        trimmedName,
224
+        area,
225
+        editNotes.trim() || null,
226
+        editPhotoUri,
227
+        now,
228
+        editingId
229
+      );
230
+      setEditModalVisible(false);
231
+      setEditingId(null);
232
+      setEditErrors({});
233
+      await fetchFieldsPage(1, true);
234
+      setStatus(t('fields.saved'));
235
+    } catch (error) {
236
+      setStatus(`Error: ${String(error)}`);
237
+    }
238
+  }
239
+
240
+  const inputStyle = [
241
+    styles.input,
242
+    {
243
+      borderColor: palette.border,
244
+      backgroundColor: palette.input,
245
+      color: palette.text,
246
+    },
247
+  ];
248
+
249
+  return (
250
+    <>
251
+    <FlatList
252
+      data={fields}
253
+      keyExtractor={(item) => String(item.id)}
254
+      extraData={[photoUri, editModalVisible, editPhotoUri, name, areaHa, notes, status]}
255
+      onEndReached={handleLoadMore}
256
+      onEndReachedThreshold={0.4}
257
+      renderItem={({ item }) => (
258
+          <Pressable onPress={() => startEdit(item)}>
259
+          <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
260
+            <ThemedText type="subtitle">{item.name || t('fields.unnamed')}</ThemedText>
261
+            {item.area_ha !== null ? (
262
+              <ThemedText style={styles.meta}>
263
+                {t('fields.areaLabel')} {item.area_ha}
264
+              </ThemedText>
265
+            ) : null}
266
+            {item.photo_uri ? (
267
+              <Image
268
+                source={{ uri: item.photo_uri }}
269
+                style={styles.photoPreview}
270
+                resizeMode="cover"
271
+                onError={(error) =>
272
+                  console.log('[Fields] List image error:', item.photo_uri, error.nativeEvent)
273
+                }
274
+                onLoad={() => console.log('[Fields] List image loaded:', item.photo_uri)}
275
+              />
276
+            ) : null}
277
+            {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
278
+            <View style={styles.buttonRow}>
279
+              <IconButton
280
+                name="trash"
281
+                onPress={() => confirmDelete(item.id)}
282
+                accessibilityLabel={t('fields.delete')}
283
+                variant="danger"
284
+              />
285
+              {item.updated_at ? (
286
+                  <ThemedText style={styles.metaEnd}>{formatDate(item.updated_at)}</ThemedText>
287
+                ) : null}
288
+              </View>
289
+            </ThemedView>
290
+          </Pressable>
291
+        )}
292
+        ItemSeparatorComponent={() => <View style={styles.separator} />}
293
+        ListHeaderComponent={
294
+          <View>
295
+            <ThemedView style={styles.hero}>
296
+              <Image source={require('@/assets/images/fields.jpg')} style={styles.heroImage} />
297
+            </ThemedView>
298
+            <ThemedView style={styles.titleContainer}>
299
+              <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
300
+                {t('fields.title')}
301
+              </ThemedText>
302
+            </ThemedView>
303
+
304
+            {status ? (
305
+              <ThemedView style={styles.section}>
306
+                <ThemedText>{status}</ThemedText>
307
+              </ThemedView>
308
+            ) : null}
309
+
310
+            <ThemedView style={styles.section}>
311
+              <Pressable
312
+                style={styles.newButton}
313
+                onPress={() => {
314
+                  setNewErrors({});
315
+                  setNewModalVisible(true);
316
+                }}>
317
+                <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
318
+                <ThemedText style={styles.newButtonText}>{t('fields.new')}</ThemedText>
319
+              </Pressable>
320
+            </ThemedView>
321
+          </View>
322
+        }
323
+        ListFooterComponent={
324
+          <View style={styles.footer}>
325
+            {loadingMore ? <ActivityIndicator /> : null}
326
+          </View>
327
+        }
328
+      />
329
+      <Modal
330
+        visible={editModalVisible}
331
+        animationType="slide"
332
+        transparent
333
+        onRequestClose={cancelEdit}>
334
+        <Pressable style={styles.sheetOverlay} onPress={cancelEdit}>
335
+          <KeyboardAvoidingView
336
+            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
337
+            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
338
+            style={styles.keyboardAvoid}>
339
+            <Pressable
340
+              style={[
341
+                styles.sheet,
342
+                { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
343
+              ]}
344
+              onPress={() => {}}>
345
+              <ScrollView
346
+                keyboardShouldPersistTaps="handled"
347
+                contentContainerStyle={styles.sheetContent}>
348
+                <ThemedText type="subtitle">{t('fields.edit')}</ThemedText>
349
+            <ThemedText>
350
+              {t('fields.name')}
351
+              <ThemedText style={styles.requiredMark}> *</ThemedText>
352
+            </ThemedText>
353
+            <TextInput
354
+              value={editName}
355
+              onChangeText={(value) => {
356
+                setEditName(value);
357
+                if (editErrors.name) {
358
+                  setEditErrors((prev) => ({ ...prev, name: undefined }));
359
+                }
360
+              }}
361
+              placeholder={t('fields.name')}
362
+              placeholderTextColor={palette.placeholder}
363
+              style={inputStyle}
364
+            />
365
+            {editErrors.name ? <ThemedText style={styles.errorText}>{editErrors.name}</ThemedText> : null}
366
+            <ThemedText>{t('fields.area')}</ThemedText>
367
+            <TextInput
368
+              value={editAreaHa}
369
+              onChangeText={(value) => {
370
+                setEditAreaHa(value);
371
+                if (editErrors.area) {
372
+                  setEditErrors((prev) => ({ ...prev, area: undefined }));
373
+                }
374
+              }}
375
+              placeholder={t('fields.areaPlaceholder')}
376
+              placeholderTextColor={palette.placeholder}
377
+              style={inputStyle}
378
+              keyboardType="decimal-pad"
379
+            />
380
+            {editErrors.area ? <ThemedText style={styles.errorText}>{editErrors.area}</ThemedText> : null}
381
+            <ThemedText>{t('fields.notes')}</ThemedText>
382
+          <TextInput
383
+            value={editNotes}
384
+            onChangeText={setEditNotes}
385
+            placeholder={t('fields.notesPlaceholder')}
386
+            placeholderTextColor={palette.placeholder}
387
+            style={inputStyle}
388
+            multiline
389
+          />
390
+          <ThemedText>{t('fields.photo')}</ThemedText>
391
+          {editPhotoUri ? (
392
+            <Image
393
+              key={editPhotoUri}
394
+              source={{ uri: editPhotoUri }}
395
+              style={styles.photoPreview}
396
+              resizeMode="cover"
397
+              onError={(error) =>
398
+                console.log('[Fields] Edit image error:', editPhotoUri, error.nativeEvent)
399
+              }
400
+              onLoad={() => console.log('[Fields] Edit image loaded:', editPhotoUri)}
401
+            />
402
+          ) : (
403
+            <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
404
+          )}
405
+          <View style={styles.photoRow}>
406
+            <ThemedButton
407
+              title={t('fields.pickPhoto')}
408
+              onPress={() => handlePickPhoto(setEditPhotoUri)}
409
+              variant="secondary"
410
+            />
411
+            <ThemedButton
412
+              title={t('fields.takePhoto')}
413
+              onPress={() =>
414
+                handleTakePhoto(setEditPhotoUri, (code) =>
415
+                  setStatus(
416
+                    code === 'cameraDenied'
417
+                      ? t('tasks.cameraDenied')
418
+                      : t('tasks.cameraError')
419
+                  )
420
+                )
421
+              }
422
+              variant="secondary"
423
+            />
424
+          </View>
425
+          <View style={styles.modalActions}>
426
+            <ThemedButton title={t('fields.cancel')} onPress={cancelEdit} variant="secondary" />
427
+            <ThemedButton title={t('fields.update')} onPress={handleUpdate} />
428
+          </View>
429
+              </ScrollView>
430
+            </Pressable>
431
+          </KeyboardAvoidingView>
432
+        </Pressable>
433
+      </Modal>
434
+      <Modal
435
+        visible={newModalVisible}
436
+        animationType="slide"
437
+        transparent
438
+        onRequestClose={() => setNewModalVisible(false)}>
439
+        <Pressable style={styles.sheetOverlay} onPress={() => setNewModalVisible(false)}>
440
+          <KeyboardAvoidingView
441
+            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
442
+            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
443
+            style={styles.keyboardAvoid}>
444
+            <Pressable
445
+              style={[
446
+                styles.sheet,
447
+                { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
448
+              ]}
449
+              onPress={() => {}}>
450
+              <ScrollView
451
+                keyboardShouldPersistTaps="handled"
452
+                contentContainerStyle={styles.sheetContent}>
453
+                <ThemedText type="subtitle">{t('fields.new')}</ThemedText>
454
+            <ThemedText>
455
+              {t('fields.name')}
456
+              <ThemedText style={styles.requiredMark}> *</ThemedText>
457
+            </ThemedText>
458
+            <TextInput
459
+              value={name}
460
+              onChangeText={(value) => {
461
+                setName(value);
462
+                if (newErrors.name) {
463
+                  setNewErrors((prev) => ({ ...prev, name: undefined }));
464
+                }
465
+              }}
466
+              placeholder={t('fields.name')}
467
+              placeholderTextColor={palette.placeholder}
468
+              style={inputStyle}
469
+            />
470
+            {newErrors.name ? <ThemedText style={styles.errorText}>{newErrors.name}</ThemedText> : null}
471
+            <ThemedText>{t('fields.area')}</ThemedText>
472
+            <TextInput
473
+              value={areaHa}
474
+              onChangeText={(value) => {
475
+                setAreaHa(value);
476
+                if (newErrors.area) {
477
+                  setNewErrors((prev) => ({ ...prev, area: undefined }));
478
+                }
479
+              }}
480
+              placeholder={t('fields.areaPlaceholder')}
481
+              placeholderTextColor={palette.placeholder}
482
+              style={inputStyle}
483
+              keyboardType="decimal-pad"
484
+            />
485
+            {newErrors.area ? <ThemedText style={styles.errorText}>{newErrors.area}</ThemedText> : null}
486
+            <ThemedText>{t('fields.notes')}</ThemedText>
487
+            <TextInput
488
+              value={notes}
489
+              onChangeText={setNotes}
490
+              placeholder={t('fields.notesPlaceholder')}
491
+              placeholderTextColor={palette.placeholder}
492
+              style={inputStyle}
493
+              multiline
494
+            />
495
+            <ThemedText>{t('fields.photo')}</ThemedText>
496
+            {photoUri ? (
497
+              <Image
498
+                key={photoUri}
499
+                source={{ uri: photoUri }}
500
+                style={styles.photoPreview}
501
+                resizeMode="cover"
502
+                onError={(error) =>
503
+                  console.log('[Fields] New image error:', photoUri, error.nativeEvent)
504
+                }
505
+                onLoad={() => console.log('[Fields] New image loaded:', photoUri)}
506
+              />
507
+            ) : (
508
+              <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
509
+            )}
510
+            <View style={styles.photoRow}>
511
+              <ThemedButton
512
+                title={t('fields.pickPhoto')}
513
+                onPress={() => handlePickPhoto(setPhotoUri)}
514
+                variant="secondary"
515
+              />
516
+              <ThemedButton
517
+                title={t('fields.takePhoto')}
518
+                onPress={() =>
519
+                  handleTakePhoto(setPhotoUri, (code) =>
520
+                    setStatus(
521
+                      code === 'cameraDenied'
522
+                        ? t('tasks.cameraDenied')
523
+                        : t('tasks.cameraError')
524
+                    )
525
+                  )
526
+                }
527
+                variant="secondary"
528
+              />
529
+            </View>
530
+            <View style={styles.modalActions}>
531
+              <ThemedButton
532
+                title={t('fields.cancel')}
533
+                onPress={() => setNewModalVisible(false)}
534
+                variant="secondary"
535
+              />
536
+              <ThemedButton
537
+                title={t('fields.save')}
538
+                onPress={async () => {
539
+                  const ok = await handleSave();
540
+                  if (ok) setNewModalVisible(false);
541
+                }}
542
+              />
543
+            </View>
544
+              </ScrollView>
545
+            </Pressable>
546
+          </KeyboardAvoidingView>
547
+        </Pressable>
548
+      </Modal>
549
+    </>
550
+  );
551
+}
552
+
553
+function formatDate(value: string) {
554
+  try {
555
+    return new Date(value).toLocaleString();
556
+  } catch {
557
+    return value;
558
+  }
559
+}
560
+
561
+async function handlePickPhoto(setter: (value: string | null) => void) {
562
+  const result = await ImagePicker.launchImageLibraryAsync({
563
+    mediaTypes: getImageMediaTypes(),
564
+    quality: 1,
565
+  });
566
+  if (result.canceled) return;
567
+  const asset = result.assets[0];
568
+  console.log('[Fields] Picked photo:', asset.uri);
569
+  setter(asset.uri);
570
+}
571
+
572
+async function handleTakePhoto(setter: (value: string | null) => void, onError?: (msg: string) => void) {
573
+  try {
574
+    const permission = await ImagePicker.requestCameraPermissionsAsync();
575
+    if (!permission.granted) {
576
+      onError?.('cameraDenied');
577
+      return;
578
+    }
579
+    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
580
+    if (result.canceled) return;
581
+    const asset = result.assets[0];
582
+    console.log('[Fields] Captured photo:', asset.uri);
583
+    setter(asset.uri);
584
+  } catch {
585
+    onError?.('cameraError');
586
+  }
587
+}
588
+
589
+function getImageMediaTypes() {
590
+  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
591
+    .MediaType;
592
+  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
593
+}
594
+
595
+
596
+const styles = StyleSheet.create({
597
+  hero: {
598
+    backgroundColor: '#E8E6DA',
599
+    aspectRatio: 16 / 9,
600
+    width: '100%',
601
+  },
602
+  heroImage: {
603
+    width: '100%',
604
+    height: '100%',
605
+  },
606
+  titleContainer: {
607
+    gap: 8,
608
+    paddingHorizontal: 16,
609
+    paddingVertical: 12,
610
+  },
611
+  section: {
612
+    gap: 8,
613
+    marginBottom: 16,
614
+    paddingHorizontal: 16,
615
+  },
616
+  newButton: {
617
+    flexDirection: 'row',
618
+    alignItems: 'center',
619
+    gap: 8,
620
+    borderRadius: 10,
621
+    borderWidth: 1,
622
+    borderColor: '#B9B9B9',
623
+    paddingHorizontal: 12,
624
+    paddingVertical: 10,
625
+    alignSelf: 'flex-start',
626
+  },
627
+  newButtonText: {
628
+    fontSize: 15,
629
+    fontWeight: '600',
630
+  },
631
+  card: {
632
+    borderRadius: 12,
633
+    borderWidth: 1,
634
+    borderColor: '#C6C6C6',
635
+    padding: 12,
636
+    marginHorizontal: 16,
637
+    gap: 6,
638
+    backgroundColor: '#FFFFFF',
639
+  },
640
+  meta: {
641
+    opacity: 0.7,
642
+  },
643
+  input: {
644
+    borderRadius: 10,
645
+    borderWidth: 1,
646
+    borderColor: '#B9B9B9',
647
+    paddingHorizontal: 12,
648
+    paddingVertical: 10,
649
+    fontSize: 15,
650
+  },
651
+  requiredMark: {
652
+    color: '#C0392B',
653
+    fontWeight: '700',
654
+  },
655
+  errorText: {
656
+    color: '#C0392B',
657
+    fontSize: 12,
658
+  },
659
+  photoPreview: {
660
+    height: 160,
661
+    width: '100%',
662
+    borderRadius: 12,
663
+  },
664
+  buttonRow: {
665
+    alignSelf: 'flex-start',
666
+    flexDirection: 'row',
667
+    gap: 8,
668
+    alignItems: 'center',
669
+    width: '100%',
670
+  },
671
+  metaEnd: {
672
+    marginLeft: 'auto',
673
+    opacity: 0.7,
674
+    fontSize: 12,
675
+  },
676
+  cancelRow: {
677
+    marginTop: 8,
678
+  },
679
+  modalActions: {
680
+    flexDirection: 'row',
681
+    justifyContent: 'space-between',
682
+    gap: 12,
683
+  },
684
+  sheetOverlay: {
685
+    flex: 1,
686
+    backgroundColor: 'rgba(0,0,0,0.3)',
687
+    justifyContent: 'flex-end',
688
+  },
689
+  sheet: {
690
+    borderTopLeftRadius: 16,
691
+    borderTopRightRadius: 16,
692
+    borderWidth: 1,
693
+    borderColor: '#C6C6C6',
694
+    padding: 16,
695
+    backgroundColor: '#FFFFFF',
696
+    gap: 10,
697
+    maxHeight: '85%',
698
+  },
699
+  sheetContent: {
700
+    gap: 10,
701
+    paddingBottom: 80,
702
+  },
703
+  keyboardAvoid: {
704
+    width: '100%',
705
+    flex: 1,
706
+    justifyContent: 'flex-end',
707
+  },
708
+  separator: {
709
+    height: 12,
710
+  },
711
+  footer: {
712
+    height: 24,
713
+  },
714
+  photoRow: {
715
+    flexDirection: 'row',
716
+    gap: 8,
717
+  },
718
+  photoPlaceholder: {
719
+    opacity: 0.6,
720
+  },
721
+});

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1039 - 0
app/harvests.tsx


+ 29 - 0
app/modal.tsx

@@ -0,0 +1,29 @@
1
+import { Link } from 'expo-router';
2
+import { StyleSheet } from 'react-native';
3
+
4
+import { ThemedText } from '@/components/themed-text';
5
+import { ThemedView } from '@/components/themed-view';
6
+
7
+export default function ModalScreen() {
8
+  return (
9
+    <ThemedView style={styles.container}>
10
+      <ThemedText type="title">This is a modal</ThemedText>
11
+      <Link href="/" dismissTo style={styles.link}>
12
+        <ThemedText type="link">Go to home screen</ThemedText>
13
+      </Link>
14
+    </ThemedView>
15
+  );
16
+}
17
+
18
+const styles = StyleSheet.create({
19
+  container: {
20
+    flex: 1,
21
+    alignItems: 'center',
22
+    justifyContent: 'center',
23
+    padding: 20,
24
+  },
25
+  link: {
26
+    marginTop: 15,
27
+    paddingVertical: 15,
28
+  },
29
+});

+ 968 - 0
app/observations.tsx

@@ -0,0 +1,968 @@
1
+import { useEffect, useMemo, useState } from 'react';
2
+import {
3
+  ActivityIndicator,
4
+  FlatList,
5
+  Image,
6
+  KeyboardAvoidingView,
7
+  Modal,
8
+  Pressable,
9
+  Platform,
10
+  StyleSheet,
11
+  TextInput,
12
+  View,
13
+} from 'react-native';
14
+import * as ImagePicker from 'expo-image-picker';
15
+
16
+import { ThemedText } from '@/components/themed-text';
17
+import { ThemedView } from '@/components/themed-view';
18
+import { IconSymbol } from '@/components/ui/icon-symbol';
19
+import { Colors, Fonts } from '@/constants/theme';
20
+import { useTranslation } from '@/localization/i18n';
21
+import { dbPromise, initCoreTables } from '@/services/db';
22
+import { useColorScheme } from '@/hooks/use-color-scheme';
23
+import { ThemedButton } from '@/components/themed-button';
24
+import { IconButton } from '@/components/icon-button';
25
+
26
+type FieldRow = {
27
+  id: number;
28
+  name: string | null;
29
+};
30
+
31
+type CropRow = {
32
+  id: number;
33
+  field_id: number | null;
34
+  crop_name: string | null;
35
+};
36
+
37
+type ObservationRow = {
38
+  id: number;
39
+  field_id: number | null;
40
+  crop_id: number | null;
41
+  obs_type: string | null;
42
+  note: string | null;
43
+  severity: number | null;
44
+  observed_at: string | null;
45
+  field_name: string | null;
46
+  crop_name: string | null;
47
+  image_uri: string | null;
48
+};
49
+
50
+export default function ObservationsScreen() {
51
+  const { t } = useTranslation();
52
+  const theme = useColorScheme() ?? 'light';
53
+  const palette = Colors[theme];
54
+  const presetTypes = [
55
+    { key: 'scouting', value: 'Scouting' },
56
+    { key: 'pest', value: 'Pest' },
57
+    { key: 'disease', value: 'Disease' },
58
+    { key: 'irrigation', value: 'Irrigation' },
59
+    { key: 'weeds', value: 'Weeds' },
60
+    { key: 'nutrients', value: 'Nutrients' },
61
+  ];
62
+  const pageSize = 12;
63
+  const [observations, setObservations] = useState<ObservationRow[]>([]);
64
+  const [fields, setFields] = useState<FieldRow[]>([]);
65
+  const [crops, setCrops] = useState<CropRow[]>([]);
66
+  const [status, setStatus] = useState(t('observations.loading'));
67
+  const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
68
+  const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
69
+  const [type, setType] = useState('scouting');
70
+  const [severity, setSeverity] = useState('');
71
+  const [note, setNote] = useState('');
72
+  const [photoUri, setPhotoUri] = useState<string | null>(null);
73
+  const [fieldModalOpen, setFieldModalOpen] = useState(false);
74
+  const [cropModalOpen, setCropModalOpen] = useState(false);
75
+  const [newModalOpen, setNewModalOpen] = useState(false);
76
+  const [reopenSheetAfterSelect, setReopenSheetAfterSelect] = useState(false);
77
+  const [editModalOpen, setEditModalOpen] = useState(false);
78
+  const [editingId, setEditingId] = useState<number | null>(null);
79
+  const [editFieldId, setEditFieldId] = useState<number | null>(null);
80
+  const [editCropId, setEditCropId] = useState<number | null>(null);
81
+  const [editType, setEditType] = useState('');
82
+  const [editSeverity, setEditSeverity] = useState('');
83
+  const [editNote, setEditNote] = useState('');
84
+  const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
85
+  const [editReopenAfterSelect, setEditReopenAfterSelect] = useState(false);
86
+  const [newErrors, setNewErrors] = useState<{ field?: string; severity?: string }>({});
87
+  const [editErrors, setEditErrors] = useState<{ field?: string; severity?: string }>({});
88
+  const [page, setPage] = useState(1);
89
+  const [hasMore, setHasMore] = useState(true);
90
+  const [loadingMore, setLoadingMore] = useState(false);
91
+
92
+  const selectedField = useMemo(
93
+    () => fields.find((item) => item.id === selectedFieldId),
94
+    [fields, selectedFieldId]
95
+  );
96
+  const selectedCrop = useMemo(
97
+    () => crops.find((item) => item.id === selectedCropId),
98
+    [crops, selectedCropId]
99
+  );
100
+  const selectedEditField = useMemo(
101
+    () => fields.find((item) => item.id === editFieldId),
102
+    [fields, editFieldId]
103
+  );
104
+  const selectedEditCrop = useMemo(
105
+    () => crops.find((item) => item.id === editCropId),
106
+    [crops, editCropId]
107
+  );
108
+
109
+  useEffect(() => {
110
+    let isActive = true;
111
+
112
+    async function loadData() {
113
+      try {
114
+        await initCoreTables();
115
+        const db = await dbPromise;
116
+        const fieldRows = await db.getAllAsync<FieldRow>(
117
+          'SELECT id, name FROM fields ORDER BY name ASC;'
118
+        );
119
+        const cropRows = await db.getAllAsync<CropRow>(
120
+          'SELECT id, field_id, crop_name FROM crops ORDER BY id DESC;'
121
+        );
122
+        if (!isActive) return;
123
+        setFields(fieldRows);
124
+        setCrops(cropRows);
125
+        await fetchObservationsPage(1, true, isActive);
126
+      } catch (error) {
127
+        if (isActive) setStatus(`Error: ${String(error)}`);
128
+      }
129
+    }
130
+
131
+    loadData();
132
+    return () => {
133
+      isActive = false;
134
+    };
135
+  }, [t]);
136
+
137
+  async function fetchObservationsPage(
138
+    pageToLoad: number,
139
+    replace: boolean,
140
+    isActive = true
141
+  ) {
142
+    try {
143
+      const db = await dbPromise;
144
+      const observationRows = await db.getAllAsync<ObservationRow>(
145
+        observationQuery,
146
+        pageSize,
147
+        (pageToLoad - 1) * pageSize
148
+      );
149
+      if (!isActive) return;
150
+      setObservations((prev) =>
151
+        replace ? observationRows : [...prev, ...observationRows]
152
+      );
153
+      setHasMore(observationRows.length === pageSize);
154
+      setPage(pageToLoad);
155
+      if (replace) {
156
+        setStatus(observationRows.length === 0 ? t('observations.empty') : '');
157
+      }
158
+    } catch (error) {
159
+      if (isActive) setStatus(`Error: ${String(error)}`);
160
+    } finally {
161
+      if (isActive) setLoadingMore(false);
162
+    }
163
+  }
164
+
165
+  async function handleLoadMore() {
166
+    if (loadingMore || !hasMore) return;
167
+    setLoadingMore(true);
168
+    const nextPage = page + 1;
169
+    await fetchObservationsPage(nextPage, false);
170
+  }
171
+
172
+  const observationQuery = `
173
+    SELECT o.id, o.field_id, o.crop_id, o.obs_type, o.note, o.severity, o.observed_at,
174
+           f.name as field_name, c.crop_name as crop_name,
175
+           i.uri as image_uri
176
+    FROM observations o
177
+    LEFT JOIN fields f ON f.id = o.field_id
178
+    LEFT JOIN crops c ON c.id = o.crop_id
179
+    LEFT JOIN images i ON i.id = (
180
+      SELECT id FROM images WHERE observation_id = o.id ORDER BY created_at DESC LIMIT 1
181
+    )
182
+    ORDER BY o.observed_at DESC
183
+    LIMIT ? OFFSET ?;`;
184
+
185
+  async function handleSave() {
186
+    const parsedSeverity = severity.trim() ? Number(severity) : null;
187
+    const nextErrors: { field?: string; severity?: string } = {};
188
+    if (!selectedFieldId) {
189
+      nextErrors.field = t('observations.fieldRequired');
190
+    }
191
+    if (severity.trim() && !Number.isFinite(parsedSeverity)) {
192
+      nextErrors.severity = t('observations.severityInvalid');
193
+    }
194
+    setNewErrors(nextErrors);
195
+    if (Object.keys(nextErrors).length > 0) {
196
+      setStatus(nextErrors.field ?? nextErrors.severity ?? t('observations.fieldRequired'));
197
+      return false;
198
+    }
199
+    try {
200
+      const db = await dbPromise;
201
+      const now = new Date().toISOString();
202
+      const result = await db.runAsync(
203
+        'INSERT INTO observations (field_id, crop_id, obs_type, note, severity, observed_at) VALUES (?, ?, ?, ?, ?, ?);',
204
+        selectedFieldId,
205
+        selectedCropId,
206
+        type.trim() || null,
207
+        note.trim() || null,
208
+        parsedSeverity,
209
+        now
210
+      );
211
+      const newId = result.lastInsertRowId;
212
+      if (photoUri && newId) {
213
+        await db.runAsync(
214
+          'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
215
+          newId,
216
+          photoUri,
217
+          now
218
+        );
219
+      }
220
+      setNote('');
221
+      setSeverity('');
222
+      setPhotoUri(null);
223
+      setNewErrors({});
224
+      setStatus(t('observations.saved'));
225
+      await fetchObservationsPage(1, true);
226
+      return true;
227
+    } catch (error) {
228
+      setStatus(`Error: ${String(error)}`);
229
+      return false;
230
+    }
231
+  }
232
+
233
+  async function handleDelete(id: number) {
234
+    try {
235
+      const db = await dbPromise;
236
+      await db.runAsync('DELETE FROM images WHERE observation_id = ?;', id);
237
+      await db.runAsync('DELETE FROM observations WHERE id = ?;', id);
238
+      await fetchObservationsPage(1, true);
239
+    } catch (error) {
240
+      setStatus(`Error: ${String(error)}`);
241
+    }
242
+  }
243
+
244
+  function startEdit(obs: ObservationRow) {
245
+    setEditingId(obs.id);
246
+    setEditFieldId(obs.field_id ?? null);
247
+    setEditCropId(obs.crop_id ?? null);
248
+    setEditType(obs.obs_type ?? '');
249
+    setEditSeverity(obs.severity !== null ? String(obs.severity) : '');
250
+    setEditNote(obs.note ?? '');
251
+    setEditPhotoUri(obs.image_uri ?? null);
252
+    setEditErrors({});
253
+    setEditModalOpen(true);
254
+  }
255
+
256
+  function cancelEdit() {
257
+    setEditingId(null);
258
+    setEditFieldId(null);
259
+    setEditCropId(null);
260
+    setEditType('');
261
+    setEditSeverity('');
262
+    setEditNote('');
263
+    setEditPhotoUri(null);
264
+    setEditErrors({});
265
+    setEditModalOpen(false);
266
+  }
267
+
268
+  async function handleUpdate() {
269
+    if (!editingId) return;
270
+    const parsedSeverity = editSeverity.trim() ? Number(editSeverity) : null;
271
+    const nextErrors: { field?: string; severity?: string } = {};
272
+    if (!editFieldId) {
273
+      nextErrors.field = t('observations.fieldRequired');
274
+    }
275
+    if (editSeverity.trim() && !Number.isFinite(parsedSeverity)) {
276
+      nextErrors.severity = t('observations.severityInvalid');
277
+    }
278
+    setEditErrors(nextErrors);
279
+    if (Object.keys(nextErrors).length > 0) {
280
+      setStatus(nextErrors.field ?? nextErrors.severity ?? t('observations.fieldRequired'));
281
+      return;
282
+    }
283
+    try {
284
+      const db = await dbPromise;
285
+      await db.runAsync(
286
+        'UPDATE observations SET field_id = ?, crop_id = ?, obs_type = ?, note = ?, severity = ? WHERE id = ?;',
287
+        editFieldId,
288
+        editCropId,
289
+        editType.trim() || null,
290
+        editNote.trim() || null,
291
+        parsedSeverity,
292
+        editingId
293
+      );
294
+      await db.runAsync('DELETE FROM images WHERE observation_id = ?;', editingId);
295
+      if (editPhotoUri) {
296
+        await db.runAsync(
297
+          'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
298
+          editingId,
299
+          editPhotoUri,
300
+          new Date().toISOString()
301
+        );
302
+      }
303
+      setEditModalOpen(false);
304
+      setEditingId(null);
305
+      setEditErrors({});
306
+      setStatus(t('observations.saved'));
307
+      await fetchObservationsPage(1, true);
308
+    } catch (error) {
309
+      setStatus(`Error: ${String(error)}`);
310
+    }
311
+  }
312
+
313
+  const inputStyle = [
314
+    styles.input,
315
+    { borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
316
+  ];
317
+
318
+  return (
319
+    <>
320
+      <FlatList
321
+        data={observations}
322
+        keyExtractor={(item) => String(item.id)}
323
+        onEndReached={handleLoadMore}
324
+        onEndReachedThreshold={0.4}
325
+        renderItem={({ item }) => (
326
+          <Pressable onPress={() => startEdit(item)}>
327
+          <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
328
+              <ThemedText type="subtitle">{item.obs_type || t('observations.untitled')}</ThemedText>
329
+              <ThemedText style={styles.meta}>
330
+                {item.field_name || t('observations.noField')} ·{' '}
331
+                {item.crop_name || t('observations.noCrop')}
332
+              </ThemedText>
333
+              {item.severity !== null ? (
334
+                <ThemedText style={styles.meta}>
335
+                  {t('observations.severityLabel')} {item.severity}
336
+                </ThemedText>
337
+              ) : null}
338
+              {item.note ? <ThemedText>{item.note}</ThemedText> : null}
339
+              {item.image_uri ? (
340
+                <Image
341
+                  source={{ uri: item.image_uri }}
342
+                  style={styles.photoPreview}
343
+                  resizeMode="cover"
344
+                />
345
+              ) : null}
346
+              {item.observed_at ? (
347
+                <ThemedText style={styles.meta}>{formatDate(item.observed_at)}</ThemedText>
348
+              ) : null}
349
+              <View style={styles.buttonRow}>
350
+                <IconButton
351
+                  name="trash"
352
+                  onPress={() => handleDelete(item.id)}
353
+                  accessibilityLabel={t('observations.delete')}
354
+                  variant="danger"
355
+                />
356
+              </View>
357
+            </ThemedView>
358
+          </Pressable>
359
+        )}
360
+        ItemSeparatorComponent={() => <View style={styles.separator} />}
361
+        ListHeaderComponent={
362
+          <View>
363
+            <ThemedView style={styles.hero}>
364
+              <Image source={require('@/assets/images/observations.jpg')} style={styles.heroImage} />
365
+            </ThemedView>
366
+            <ThemedView style={styles.titleContainer}>
367
+              <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
368
+                {t('observations.title')}
369
+              </ThemedText>
370
+            </ThemedView>
371
+
372
+            {status ? (
373
+              <ThemedView style={styles.section}>
374
+                <ThemedText>{status}</ThemedText>
375
+              </ThemedView>
376
+            ) : null}
377
+            <ThemedView style={styles.section}>
378
+              <Pressable
379
+                style={styles.newButton}
380
+                onPress={() => {
381
+                  setNewErrors({});
382
+                  setNewModalOpen(true);
383
+                }}>
384
+                <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
385
+                <ThemedText style={styles.newButtonText}>{t('observations.new')}</ThemedText>
386
+              </Pressable>
387
+            </ThemedView>
388
+          </View>
389
+        }
390
+        ListFooterComponent={
391
+          <View style={styles.footer}>
392
+            {loadingMore ? <ActivityIndicator /> : null}
393
+          </View>
394
+        }
395
+      />
396
+
397
+      <Modal transparent visible={fieldModalOpen} animationType="fade">
398
+        <Pressable
399
+          style={styles.modalOverlay}
400
+          onPress={() => {
401
+            setFieldModalOpen(false);
402
+            setReopenSheetAfterSelect(false);
403
+            setEditReopenAfterSelect(false);
404
+          }}>
405
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
406
+            <ThemedText type="subtitle">{t('observations.selectField')}</ThemedText>
407
+            <FlatList
408
+              data={fields}
409
+              keyExtractor={(item) => String(item.id)}
410
+              renderItem={({ item }) => (
411
+                <Pressable
412
+                  onPress={() => {
413
+                    if (editReopenAfterSelect) {
414
+                      setEditFieldId(item.id);
415
+                      setEditCropId(null);
416
+                      setEditErrors((prev) => ({ ...prev, field: undefined }));
417
+                      setEditModalOpen(true);
418
+                      setEditReopenAfterSelect(false);
419
+                    } else {
420
+                      setSelectedFieldId(item.id);
421
+                      setSelectedCropId(null);
422
+                      setNewErrors((prev) => ({ ...prev, field: undefined }));
423
+                      setNewModalOpen(true);
424
+                      setReopenSheetAfterSelect(false);
425
+                    }
426
+                    setFieldModalOpen(false);
427
+                  }}
428
+                  style={styles.modalItem}>
429
+                  <ThemedText>{item.name || t('observations.untitled')}</ThemedText>
430
+                </Pressable>
431
+              )}
432
+            />
433
+          </View>
434
+        </Pressable>
435
+      </Modal>
436
+
437
+      <Modal transparent visible={cropModalOpen} animationType="fade">
438
+        <Pressable
439
+          style={styles.modalOverlay}
440
+          onPress={() => {
441
+            setCropModalOpen(false);
442
+            setReopenSheetAfterSelect(false);
443
+            setEditReopenAfterSelect(false);
444
+          }}>
445
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
446
+            <ThemedText type="subtitle">{t('observations.selectCrop')}</ThemedText>
447
+            <FlatList
448
+              data={crops.filter((item) => !selectedFieldId || item.field_id === selectedFieldId)}
449
+              keyExtractor={(item) => String(item.id)}
450
+              renderItem={({ item }) => (
451
+                <Pressable
452
+                  onPress={() => {
453
+                    if (editReopenAfterSelect) {
454
+                      setEditCropId(item.id);
455
+                      setEditModalOpen(true);
456
+                      setEditReopenAfterSelect(false);
457
+                    } else {
458
+                      setSelectedCropId(item.id);
459
+                      setNewModalOpen(true);
460
+                      setReopenSheetAfterSelect(false);
461
+                    }
462
+                    setCropModalOpen(false);
463
+                  }}
464
+                  style={styles.modalItem}>
465
+                  <ThemedText>{item.crop_name || t('observations.untitled')}</ThemedText>
466
+                </Pressable>
467
+              )}
468
+              ListEmptyComponent={
469
+                <ThemedText style={styles.meta}>{t('observations.noCrop')}</ThemedText>
470
+              }
471
+            />
472
+          </View>
473
+        </Pressable>
474
+      </Modal>
475
+
476
+      <Modal transparent visible={newModalOpen} animationType="slide">
477
+        <View style={styles.sheetOverlay}>
478
+          <Pressable style={styles.sheetBackdrop} onPress={() => setNewModalOpen(false)} />
479
+          <KeyboardAvoidingView
480
+            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
481
+            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
482
+            style={styles.keyboardAvoid}>
483
+            <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
484
+              <FlatList
485
+                data={[{ key: 'new' }]}
486
+                keyExtractor={(item) => item.key}
487
+                contentContainerStyle={styles.sheetListContent}
488
+                renderItem={() => (
489
+                  <View style={styles.sheetContent}>
490
+                  <ThemedText type="subtitle">{t('observations.new')}</ThemedText>
491
+                  <ThemedText>
492
+                    {t('observations.field')}
493
+                    <ThemedText style={styles.requiredMark}> *</ThemedText>
494
+                  </ThemedText>
495
+                  <ThemedButton
496
+                    title={selectedField?.name || t('observations.selectField')}
497
+                    onPress={() => {
498
+                      setReopenSheetAfterSelect(true);
499
+                      setNewModalOpen(false);
500
+                      setFieldModalOpen(true);
501
+                    }}
502
+                    variant="secondary"
503
+                  />
504
+                  {newErrors.field ? (
505
+                    <ThemedText style={styles.errorText}>{newErrors.field}</ThemedText>
506
+                  ) : null}
507
+                  <ThemedText>{t('observations.crop')}</ThemedText>
508
+                  <ThemedButton
509
+                    title={selectedCrop?.crop_name || t('observations.selectCrop')}
510
+                    onPress={() => {
511
+                      setReopenSheetAfterSelect(true);
512
+                      setNewModalOpen(false);
513
+                      setCropModalOpen(true);
514
+                    }}
515
+                    variant="secondary"
516
+                  />
517
+                  <ThemedText>{t('observations.type')}</ThemedText>
518
+                  <View style={styles.chipRow}>
519
+                    {presetTypes.map((preset) => {
520
+                      const label = t(`observations.type.${preset.key}`);
521
+                      const normalized = type.trim().toLowerCase();
522
+                      const isActive =
523
+                        label.toLowerCase() === normalized ||
524
+                        preset.value.toLowerCase() === normalized;
525
+                      return (
526
+                        <Pressable
527
+                          key={preset.key}
528
+                          onPress={() => setType(label)}
529
+                          style={[styles.chip, isActive && styles.chipActive]}>
530
+                          <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
531
+                            {label}
532
+                          </ThemedText>
533
+                        </Pressable>
534
+                      );
535
+                    })}
536
+                  </View>
537
+                  <TextInput
538
+                    value={type}
539
+                    onChangeText={setType}
540
+                    placeholder={t('observations.typePlaceholder')}
541
+                    style={inputStyle}
542
+                  />
543
+                  <ThemedText>{t('observations.severity')}</ThemedText>
544
+                  <TextInput
545
+                    value={severity}
546
+                    onChangeText={(value) => {
547
+                      setSeverity(value);
548
+                      if (newErrors.severity) {
549
+                        setNewErrors((prev) => ({ ...prev, severity: undefined }));
550
+                      }
551
+                    }}
552
+                    placeholder={t('observations.severityPlaceholder')}
553
+                    style={inputStyle}
554
+                    keyboardType="numeric"
555
+                  />
556
+                  {newErrors.severity ? (
557
+                    <ThemedText style={styles.errorText}>{newErrors.severity}</ThemedText>
558
+                  ) : null}
559
+                  <ThemedText>{t('observations.note')}</ThemedText>
560
+                  <TextInput
561
+                    value={note}
562
+                    onChangeText={setNote}
563
+                    placeholder={t('observations.notePlaceholder')}
564
+                    style={inputStyle}
565
+                    multiline
566
+                  />
567
+                  <ThemedText>{t('observations.photo')}</ThemedText>
568
+                  {photoUri ? (
569
+                    <Image source={{ uri: photoUri }} style={styles.photoPreview} resizeMode="cover" />
570
+                  ) : (
571
+                    <ThemedText style={styles.photoPlaceholder}>{t('observations.noPhoto')}</ThemedText>
572
+                  )}
573
+                  <View style={styles.photoRow}>
574
+                    <ThemedButton
575
+                      title={t('observations.pickPhoto')}
576
+                      onPress={() => handlePickPhoto(setPhotoUri)}
577
+                      variant="secondary"
578
+                    />
579
+                    <ThemedButton
580
+                      title={t('observations.takePhoto')}
581
+                      onPress={() =>
582
+                        handleTakePhoto(setPhotoUri, (code) =>
583
+                          setStatus(
584
+                            code === 'cameraDenied'
585
+                              ? t('tasks.cameraDenied')
586
+                              : t('tasks.cameraError')
587
+                          )
588
+                        )
589
+                      }
590
+                      variant="secondary"
591
+                    />
592
+                  </View>
593
+                  <View style={styles.modalActions}>
594
+                    <ThemedButton
595
+                      title={t('observations.cancel')}
596
+                      onPress={() => setNewModalOpen(false)}
597
+                      variant="secondary"
598
+                    />
599
+                    <ThemedButton
600
+                      title={t('observations.save')}
601
+                      onPress={async () => {
602
+                        const ok = await handleSave();
603
+                        if (ok) setNewModalOpen(false);
604
+                      }}
605
+                    />
606
+                  </View>
607
+                  <View style={styles.sheetFooter} />
608
+                </View>
609
+                )}
610
+              />
611
+            </View>
612
+          </KeyboardAvoidingView>
613
+        </View>
614
+      </Modal>
615
+
616
+      <Modal transparent visible={editModalOpen} animationType="slide">
617
+        <View style={styles.sheetOverlay}>
618
+          <Pressable style={styles.sheetBackdrop} onPress={cancelEdit} />
619
+          <KeyboardAvoidingView
620
+            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
621
+            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
622
+            style={styles.keyboardAvoid}>
623
+            <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
624
+              <FlatList
625
+                data={[{ key: 'edit' }]}
626
+                keyExtractor={(item) => item.key}
627
+                contentContainerStyle={styles.sheetListContent}
628
+                renderItem={() => (
629
+                  <View style={styles.sheetContent}>
630
+                  <ThemedText type="subtitle">{t('observations.edit')}</ThemedText>
631
+                  <ThemedText>
632
+                    {t('observations.field')}
633
+                    <ThemedText style={styles.requiredMark}> *</ThemedText>
634
+                  </ThemedText>
635
+                  <ThemedButton
636
+                    title={selectedEditField?.name || t('observations.selectField')}
637
+                    onPress={() => {
638
+                      setEditReopenAfterSelect(true);
639
+                      setEditModalOpen(false);
640
+                      setFieldModalOpen(true);
641
+                    }}
642
+                    variant="secondary"
643
+                  />
644
+                  {editErrors.field ? (
645
+                    <ThemedText style={styles.errorText}>{editErrors.field}</ThemedText>
646
+                  ) : null}
647
+                  <ThemedText>{t('observations.crop')}</ThemedText>
648
+                  <ThemedButton
649
+                    title={selectedEditCrop?.crop_name || t('observations.selectCrop')}
650
+                    onPress={() => {
651
+                      setEditReopenAfterSelect(true);
652
+                      setEditModalOpen(false);
653
+                      setCropModalOpen(true);
654
+                    }}
655
+                    variant="secondary"
656
+                  />
657
+                  <ThemedText>{t('observations.type')}</ThemedText>
658
+                  <View style={styles.chipRow}>
659
+                    {presetTypes.map((preset) => {
660
+                      const label = t(`observations.type.${preset.key}`);
661
+                      const normalized = editType.trim().toLowerCase();
662
+                      const isActive =
663
+                        label.toLowerCase() === normalized ||
664
+                        preset.value.toLowerCase() === normalized;
665
+                      return (
666
+                        <Pressable
667
+                          key={`edit-${preset.key}`}
668
+                          onPress={() => setEditType(label)}
669
+                          style={[styles.chip, isActive && styles.chipActive]}>
670
+                          <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
671
+                            {label}
672
+                          </ThemedText>
673
+                        </Pressable>
674
+                      );
675
+                    })}
676
+                  </View>
677
+                  <TextInput
678
+                    value={editType}
679
+                    onChangeText={setEditType}
680
+                    placeholder={t('observations.typePlaceholder')}
681
+                    style={inputStyle}
682
+                  />
683
+                  <ThemedText>{t('observations.severity')}</ThemedText>
684
+                  <TextInput
685
+                    value={editSeverity}
686
+                    onChangeText={(value) => {
687
+                      setEditSeverity(value);
688
+                      if (editErrors.severity) {
689
+                        setEditErrors((prev) => ({ ...prev, severity: undefined }));
690
+                      }
691
+                    }}
692
+                    placeholder={t('observations.severityPlaceholder')}
693
+                    style={inputStyle}
694
+                    keyboardType="numeric"
695
+                  />
696
+                  {editErrors.severity ? (
697
+                    <ThemedText style={styles.errorText}>{editErrors.severity}</ThemedText>
698
+                  ) : null}
699
+                  <ThemedText>{t('observations.note')}</ThemedText>
700
+                  <TextInput
701
+                    value={editNote}
702
+                    onChangeText={setEditNote}
703
+                    placeholder={t('observations.notePlaceholder')}
704
+                    style={inputStyle}
705
+                    multiline
706
+                  />
707
+                  <ThemedText>{t('observations.photo')}</ThemedText>
708
+                  {editPhotoUri ? (
709
+                    <Image
710
+                      source={{ uri: editPhotoUri }}
711
+                      style={styles.photoPreview}
712
+                      resizeMode="cover"
713
+                    />
714
+                  ) : (
715
+                    <ThemedText style={styles.photoPlaceholder}>
716
+                      {t('observations.noPhoto')}
717
+                    </ThemedText>
718
+                  )}
719
+                  <View style={styles.photoRow}>
720
+                    <ThemedButton
721
+                      title={t('observations.pickPhoto')}
722
+                      onPress={() => handlePickPhoto(setEditPhotoUri)}
723
+                      variant="secondary"
724
+                    />
725
+                    <ThemedButton
726
+                      title={t('observations.takePhoto')}
727
+                      onPress={() =>
728
+                        handleTakePhoto(setEditPhotoUri, (code) =>
729
+                          setStatus(
730
+                            code === 'cameraDenied'
731
+                              ? t('tasks.cameraDenied')
732
+                              : t('tasks.cameraError')
733
+                          )
734
+                        )
735
+                      }
736
+                      variant="secondary"
737
+                    />
738
+                  </View>
739
+                  <View style={styles.modalActions}>
740
+                    <ThemedButton
741
+                      title={t('observations.cancel')}
742
+                      onPress={cancelEdit}
743
+                      variant="secondary"
744
+                    />
745
+                    <ThemedButton title={t('observations.update')} onPress={handleUpdate} />
746
+                  </View>
747
+                  <View style={styles.sheetFooter} />
748
+                </View>
749
+                )}
750
+              />
751
+            </View>
752
+          </KeyboardAvoidingView>
753
+        </View>
754
+      </Modal>
755
+    </>
756
+  );
757
+}
758
+
759
+function formatDate(value: string) {
760
+  try {
761
+    return new Date(value).toLocaleString();
762
+  } catch {
763
+    return value;
764
+  }
765
+}
766
+
767
+async function handlePickPhoto(setter: (value: string | null) => void) {
768
+  const result = await ImagePicker.launchImageLibraryAsync({
769
+    mediaTypes: getImageMediaTypes(),
770
+    quality: 1,
771
+  });
772
+  if (result.canceled) return;
773
+  const asset = result.assets[0];
774
+  console.log('[Observations] Picked photo:', asset.uri);
775
+  setter(asset.uri);
776
+}
777
+
778
+async function handleTakePhoto(
779
+  setter: (value: string | null) => void,
780
+  onError?: (msg: string) => void
781
+) {
782
+  try {
783
+    const permission = await ImagePicker.requestCameraPermissionsAsync();
784
+    if (!permission.granted) {
785
+      onError?.('cameraDenied');
786
+      return;
787
+    }
788
+    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
789
+    if (result.canceled) return;
790
+    const asset = result.assets[0];
791
+    console.log('[Observations] Captured photo:', asset.uri);
792
+    setter(asset.uri);
793
+  } catch {
794
+    onError?.('cameraError');
795
+  }
796
+}
797
+
798
+function getImageMediaTypes() {
799
+  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
800
+    .MediaType;
801
+  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
802
+}
803
+
804
+const styles = StyleSheet.create({
805
+  hero: {
806
+    backgroundColor: '#E8E6DA',
807
+    aspectRatio: 16 / 9,
808
+    width: '100%',
809
+  },
810
+  heroImage: {
811
+    width: '100%',
812
+    height: '100%',
813
+  },
814
+  titleContainer: {
815
+    gap: 8,
816
+    paddingHorizontal: 16,
817
+    paddingVertical: 12,
818
+  },
819
+  section: {
820
+    gap: 8,
821
+    marginBottom: 16,
822
+    paddingHorizontal: 16,
823
+  },
824
+  card: {
825
+    borderRadius: 12,
826
+    borderWidth: 1,
827
+    borderColor: '#C6C6C6',
828
+    padding: 12,
829
+    marginHorizontal: 16,
830
+    gap: 6,
831
+    backgroundColor: '#FFFFFF',
832
+  },
833
+  meta: {
834
+    opacity: 0.7,
835
+  },
836
+  input: {
837
+    borderRadius: 10,
838
+    borderWidth: 1,
839
+    borderColor: '#B9B9B9',
840
+    paddingHorizontal: 12,
841
+    paddingVertical: 10,
842
+    fontSize: 15,
843
+  },
844
+  requiredMark: {
845
+    color: '#C0392B',
846
+    fontWeight: '700',
847
+  },
848
+  errorText: {
849
+    color: '#C0392B',
850
+    fontSize: 12,
851
+  },
852
+  buttonRow: {
853
+    alignSelf: 'flex-start',
854
+  },
855
+  separator: {
856
+    height: 12,
857
+  },
858
+  footer: {
859
+    height: 24,
860
+  },
861
+  modalOverlay: {
862
+    flex: 1,
863
+    backgroundColor: 'rgba(0,0,0,0.3)',
864
+    justifyContent: 'center',
865
+    padding: 16,
866
+  },
867
+  modalCard: {
868
+    borderRadius: 12,
869
+    borderWidth: 1,
870
+    borderColor: '#C6C6C6',
871
+    padding: 16,
872
+    backgroundColor: '#FFFFFF',
873
+    gap: 8,
874
+    maxHeight: '70%',
875
+  },
876
+  modalItem: {
877
+    paddingVertical: 8,
878
+  },
879
+  newButton: {
880
+    flexDirection: 'row',
881
+    alignItems: 'center',
882
+    gap: 8,
883
+    borderRadius: 10,
884
+    borderWidth: 1,
885
+    borderColor: '#B9B9B9',
886
+    paddingHorizontal: 12,
887
+    paddingVertical: 10,
888
+    alignSelf: 'flex-start',
889
+  },
890
+  newButtonText: {
891
+    fontSize: 15,
892
+    fontWeight: '600',
893
+  },
894
+  modalActions: {
895
+    flexDirection: 'row',
896
+    justifyContent: 'space-between',
897
+    gap: 12,
898
+  },
899
+  chipRow: {
900
+    flexDirection: 'row',
901
+    flexWrap: 'wrap',
902
+    gap: 8,
903
+  },
904
+  chip: {
905
+    borderRadius: 999,
906
+    borderWidth: 1,
907
+    borderColor: '#C6C6C6',
908
+    paddingHorizontal: 12,
909
+    paddingVertical: 6,
910
+  },
911
+  chipActive: {
912
+    borderColor: '#2F7D4F',
913
+    backgroundColor: '#E7F3EA',
914
+  },
915
+  chipText: {
916
+    fontSize: 13,
917
+  },
918
+  chipTextActive: {
919
+    fontSize: 13,
920
+    color: '#2F7D4F',
921
+    fontWeight: '600',
922
+  },
923
+  photoRow: {
924
+    flexDirection: 'row',
925
+    gap: 12,
926
+  },
927
+  photoPlaceholder: {
928
+    opacity: 0.6,
929
+  },
930
+  photoPreview: {
931
+    width: '100%',
932
+    height: 160,
933
+    borderRadius: 12,
934
+  },
935
+  sheetOverlay: {
936
+    flex: 1,
937
+    backgroundColor: 'rgba(0,0,0,0.3)',
938
+    justifyContent: 'flex-end',
939
+  },
940
+  sheetBackdrop: {
941
+    ...StyleSheet.absoluteFillObject,
942
+  },
943
+  sheet: {
944
+    borderTopLeftRadius: 16,
945
+    borderTopRightRadius: 16,
946
+    borderWidth: 1,
947
+    borderColor: '#C6C6C6',
948
+    padding: 16,
949
+    backgroundColor: '#FFFFFF',
950
+    gap: 10,
951
+    maxHeight: '85%',
952
+  },
953
+  sheetContent: {
954
+    gap: 10,
955
+    paddingBottom: 8,
956
+  },
957
+  sheetListContent: {
958
+    paddingBottom: 80,
959
+  },
960
+  sheetFooter: {
961
+    height: 24,
962
+  },
963
+  keyboardAvoid: {
964
+    width: '100%',
965
+    flex: 1,
966
+    justifyContent: 'flex-end',
967
+  },
968
+});

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1139 - 0
app/sales.tsx


+ 166 - 0
app/task-history.tsx

@@ -0,0 +1,166 @@
1
+import { useEffect, useState } from 'react';
2
+import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native';
3
+import { Image } from 'expo-image';
4
+
5
+import { ThemedText } from '@/components/themed-text';
6
+import { ThemedView } from '@/components/themed-view';
7
+import { Colors, Fonts } from '@/constants/theme';
8
+import { useTranslation } from '@/localization/i18n';
9
+import { dbPromise, initCoreTables } from '@/services/db';
10
+import { useColorScheme } from '@/hooks/use-color-scheme';
11
+
12
+type EntryRow = {
13
+  id: number;
14
+  name: string;
15
+  status: string | null;
16
+  notes: string | null;
17
+  completed_at: string | null;
18
+};
19
+
20
+export default function TaskHistoryScreen() {
21
+  const { t } = useTranslation();
22
+  const theme = useColorScheme() ?? 'light';
23
+  const palette = Colors[theme];
24
+  const pageSize = 12;
25
+  const [entries, setEntries] = useState<EntryRow[]>([]);
26
+  const [status, setStatus] = useState(t('tasks.loading'));
27
+  const [page, setPage] = useState(1);
28
+  const [hasMore, setHasMore] = useState(true);
29
+  const [loadingMore, setLoadingMore] = useState(false);
30
+
31
+  useEffect(() => {
32
+    let isActive = true;
33
+
34
+    async function loadEntries() {
35
+      await fetchEntriesPage(1, true, isActive);
36
+    }
37
+
38
+    loadEntries();
39
+    return () => {
40
+      isActive = false;
41
+    };
42
+  }, [t]);
43
+
44
+  async function fetchEntriesPage(pageToLoad: number, replace: boolean, isActive = true) {
45
+    try {
46
+      await initCoreTables();
47
+      const db = await dbPromise;
48
+      const rows = await db.getAllAsync<EntryRow>(
49
+        `SELECT e.id, t.name, e.status, e.notes, e.completed_at
50
+         FROM daily_task_entries e
51
+         JOIN daily_tasks t ON t.id = e.task_id
52
+         ORDER BY e.completed_at DESC
53
+         LIMIT ? OFFSET ?;`,
54
+        pageSize,
55
+        (pageToLoad - 1) * pageSize
56
+      );
57
+      if (!isActive) return;
58
+      setEntries((prev) => (replace ? rows : [...prev, ...rows]));
59
+      setHasMore(rows.length === pageSize);
60
+      setPage(pageToLoad);
61
+      if (replace) {
62
+        setStatus(rows.length === 0 ? t('tasks.historyEmpty') : '');
63
+      }
64
+    } catch (error) {
65
+      if (isActive) setStatus(`Error: ${String(error)}`);
66
+    } finally {
67
+      if (isActive) setLoadingMore(false);
68
+    }
69
+  }
70
+
71
+  async function handleLoadMore() {
72
+    if (loadingMore || !hasMore) return;
73
+    setLoadingMore(true);
74
+    const nextPage = page + 1;
75
+    await fetchEntriesPage(nextPage, false);
76
+  }
77
+
78
+  return (
79
+    <FlatList
80
+      data={entries}
81
+      keyExtractor={(item) => String(item.id)}
82
+      onEndReached={handleLoadMore}
83
+      onEndReachedThreshold={0.4}
84
+      renderItem={({ item }) => (
85
+        <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
86
+          <ThemedText type="subtitle">{item.name}</ThemedText>
87
+          <ThemedText style={styles.meta}>
88
+            {item.completed_at ? formatDate(item.completed_at) : '-'}
89
+          </ThemedText>
90
+          {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
91
+        </ThemedView>
92
+      )}
93
+      ItemSeparatorComponent={() => <View style={styles.separator} />}
94
+      ListHeaderComponent={
95
+        <View>
96
+          <ThemedView style={styles.hero}>
97
+            <Image source={require('@/assets/images/dailytask.jpg')} style={styles.heroImage} />
98
+          </ThemedView>
99
+          <ThemedView style={styles.titleContainer}>
100
+            <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
101
+              {t('tasks.historyTitle')}
102
+            </ThemedText>
103
+          </ThemedView>
104
+          {status ? (
105
+            <ThemedView style={styles.section}>
106
+              <ThemedText>{status}</ThemedText>
107
+            </ThemedView>
108
+          ) : null}
109
+        </View>
110
+      }
111
+      ListFooterComponent={
112
+        <View style={styles.footer}>
113
+          {loadingMore ? <ActivityIndicator /> : null}
114
+        </View>
115
+      }
116
+    />
117
+  );
118
+}
119
+
120
+function formatDate(value: string) {
121
+  try {
122
+    return new Date(value).toLocaleString();
123
+  } catch {
124
+    return value;
125
+  }
126
+}
127
+
128
+const styles = StyleSheet.create({
129
+  hero: {
130
+    backgroundColor: '#E8E6DA',
131
+    aspectRatio: 16 / 9,
132
+    width: '100%',
133
+  },
134
+  heroImage: {
135
+    width: '100%',
136
+    height: '100%',
137
+  },
138
+  titleContainer: {
139
+    gap: 8,
140
+    paddingHorizontal: 16,
141
+    paddingVertical: 12,
142
+  },
143
+  section: {
144
+    gap: 8,
145
+    marginBottom: 16,
146
+    paddingHorizontal: 16,
147
+  },
148
+  card: {
149
+    borderRadius: 12,
150
+    borderWidth: 1,
151
+    borderColor: '#C6C6C6',
152
+    padding: 12,
153
+    marginHorizontal: 16,
154
+    gap: 6,
155
+    backgroundColor: '#FFFFFF',
156
+  },
157
+  meta: {
158
+    opacity: 0.7,
159
+  },
160
+  separator: {
161
+    height: 12,
162
+  },
163
+  footer: {
164
+    height: 24,
165
+  },
166
+});

+ 455 - 0
app/tasks.tsx

@@ -0,0 +1,455 @@
1
+import { useEffect, useMemo, useState } from 'react';
2
+import {
3
+  ActivityIndicator,
4
+  FlatList,
5
+  KeyboardAvoidingView,
6
+  Platform,
7
+  StyleSheet,
8
+  TextInput,
9
+  View,
10
+} from 'react-native';
11
+import { Image } from 'expo-image';
12
+import * as ImageManipulator from 'expo-image-manipulator';
13
+import * as ImagePicker from 'expo-image-picker';
14
+import { Link } from 'expo-router';
15
+
16
+import { ThemedText } from '@/components/themed-text';
17
+import { ThemedView } from '@/components/themed-view';
18
+import { Colors, Fonts } from '@/constants/theme';
19
+import { ThemedButton } from '@/components/themed-button';
20
+import { useTranslation } from '@/localization/i18n';
21
+import { dbPromise, initCoreTables } from '@/services/db';
22
+import { useColorScheme } from '@/hooks/use-color-scheme';
23
+
24
+type TaskRow = {
25
+  id: number;
26
+  name: string;
27
+  description: string | null;
28
+  entry_id: number | null;
29
+  status: string | null;
30
+  notes: string | null;
31
+  meta_json: string | null;
32
+};
33
+
34
+export default function DailyTasksScreen() {
35
+  const { t } = useTranslation();
36
+  const theme = useColorScheme() ?? 'light';
37
+  const palette = Colors[theme];
38
+  const defaultTasks = [
39
+    {
40
+      name: t('tasks.default.fieldCheck'),
41
+      description: t('tasks.default.fieldCheckDesc'),
42
+      default_time: '08:00',
43
+    },
44
+    {
45
+      name: t('tasks.default.scouting'),
46
+      description: t('tasks.default.scoutingDesc'),
47
+      default_time: '10:00',
48
+    },
49
+    {
50
+      name: t('tasks.default.sensors'),
51
+      description: t('tasks.default.sensorsDesc'),
52
+      default_time: '15:00',
53
+    },
54
+  ];
55
+  const pageSize = 10;
56
+  const [tasks, setTasks] = useState<TaskRow[]>([]);
57
+  const [status, setStatus] = useState(t('tasks.loading'));
58
+  const [notesByTaskId, setNotesByTaskId] = useState<Record<number, string>>({});
59
+  const [photoByTaskId, setPhotoByTaskId] = useState<Record<number, string>>({});
60
+  const [page, setPage] = useState(1);
61
+  const [hasMore, setHasMore] = useState(true);
62
+  const [loadingMore, setLoadingMore] = useState(false);
63
+
64
+  const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
65
+
66
+  useEffect(() => {
67
+    let isActive = true;
68
+
69
+    async function loadTasks() {
70
+      try {
71
+        await initCoreTables();
72
+        const db = await dbPromise;
73
+        await ensureDefaultTasks(db, defaultTasks);
74
+        await fetchTasksPage(1, true, isActive);
75
+      } catch (error) {
76
+        if (isActive) setStatus(`Error: ${String(error)}`);
77
+      }
78
+    }
79
+
80
+    loadTasks();
81
+    return () => {
82
+      isActive = false;
83
+    };
84
+  }, [t, todayKey]);
85
+
86
+  async function fetchTasksPage(pageToLoad: number, replace: boolean, isActive = true) {
87
+    try {
88
+      const db = await dbPromise;
89
+      const rows = await db.getAllAsync<TaskRow>(
90
+        `SELECT t.id, t.name, t.description,
91
+                e.id as entry_id, e.status, e.notes, e.meta_json
92
+         FROM daily_tasks t
93
+         LEFT JOIN daily_task_entries e
94
+           ON e.task_id = t.id
95
+          AND substr(e.completed_at, 1, 10) = ?
96
+         WHERE t.is_active = 1
97
+         ORDER BY t.id ASC
98
+         LIMIT ? OFFSET ?;`,
99
+        todayKey,
100
+        pageSize,
101
+        (pageToLoad - 1) * pageSize
102
+      );
103
+      if (!isActive) return;
104
+      setTasks((prev) => (replace ? rows : [...prev, ...rows]));
105
+      setHasMore(rows.length === pageSize);
106
+      setPage(pageToLoad);
107
+      if (replace) {
108
+        setStatus(rows.length === 0 ? t('tasks.empty') : '');
109
+      }
110
+      setNotesByTaskId((prev) => {
111
+        const next = replace ? {} : { ...prev };
112
+        rows.forEach((row) => {
113
+          if (row.notes) next[row.id] = row.notes;
114
+        });
115
+        return next;
116
+      });
117
+      setPhotoByTaskId((prev) => {
118
+        const next = replace ? {} : { ...prev };
119
+        rows.forEach((row) => {
120
+          const meta = parseTaskMeta(row.meta_json);
121
+          if (meta.photoUri) next[row.id] = meta.photoUri;
122
+        });
123
+        return next;
124
+      });
125
+    } catch (error) {
126
+      if (isActive) setStatus(`Error: ${String(error)}`);
127
+    } finally {
128
+      if (isActive) setLoadingMore(false);
129
+    }
130
+  }
131
+
132
+  async function handleLoadMore() {
133
+    if (loadingMore || !hasMore) return;
134
+    setLoadingMore(true);
135
+    const nextPage = page + 1;
136
+    await fetchTasksPage(nextPage, false);
137
+  }
138
+
139
+  async function handleComplete(task: TaskRow) {
140
+    try {
141
+      const db = await dbPromise;
142
+      const now = new Date().toISOString();
143
+      const notes = (notesByTaskId[task.id] ?? '').trim();
144
+      const metaJson = serializeTaskMeta({
145
+        photoUri: photoByTaskId[task.id],
146
+      });
147
+      if (task.entry_id) {
148
+        await db.runAsync('DELETE FROM daily_task_entries WHERE id = ?;', task.entry_id);
149
+      } else {
150
+        await db.runAsync(
151
+          'INSERT INTO daily_task_entries (task_id, field_id, notes, status, completed_at, created_at, meta_json) VALUES (?, NULL, ?, ?, ?, ?, ?);',
152
+          task.id,
153
+          notes,
154
+          'done',
155
+          now,
156
+          now,
157
+          metaJson
158
+        );
159
+      }
160
+      await fetchTasksPage(1, true);
161
+    } catch (error) {
162
+      setStatus(`Error: ${String(error)}`);
163
+    }
164
+  }
165
+
166
+  async function handlePickPhoto(task: TaskRow) {
167
+    const result = await ImagePicker.launchImageLibraryAsync({
168
+      mediaTypes: getImageMediaTypes(),
169
+      quality: 1,
170
+    });
171
+    if (result.canceled) return;
172
+    const asset = result.assets[0];
173
+    console.log('[Tasks] Picked photo:', asset.uri);
174
+    const normalized = await normalizeImageUri(asset.uri);
175
+    console.log('[Tasks] Normalized photo:', normalized);
176
+    await persistTaskPhoto(task, normalized, setPhotoByTaskId);
177
+  }
178
+
179
+  async function handleTakePhoto(task: TaskRow) {
180
+    try {
181
+      const permission = await ImagePicker.requestCameraPermissionsAsync();
182
+      if (!permission.granted) {
183
+        setStatus(t('tasks.cameraDenied'));
184
+        return;
185
+      }
186
+      const result = await ImagePicker.launchCameraAsync({
187
+        quality: 1,
188
+      });
189
+      if (result.canceled) return;
190
+      const asset = result.assets[0];
191
+      console.log('[Tasks] Captured photo:', asset.uri);
192
+      const normalized = await normalizeImageUri(asset.uri);
193
+      console.log('[Tasks] Normalized photo:', normalized);
194
+      await persistTaskPhoto(task, normalized, setPhotoByTaskId);
195
+    } catch (error) {
196
+      setStatus(t('tasks.cameraError'));
197
+    }
198
+  }
199
+
200
+  return (
201
+    <KeyboardAvoidingView
202
+      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
203
+      keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
204
+      style={styles.keyboardAvoid}>
205
+      <FlatList
206
+        data={tasks}
207
+        keyExtractor={(item) => String(item.id)}
208
+        keyboardShouldPersistTaps="handled"
209
+        keyboardDismissMode="on-drag"
210
+        contentContainerStyle={styles.listContent}
211
+        renderItem={({ item }) => {
212
+        const isDone = Boolean(item.entry_id);
213
+        return (
214
+          <ThemedView
215
+            style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
216
+            <ThemedView style={styles.cardHeader}>
217
+              <ThemedText type="subtitle" style={[styles.taskTitle, { color: palette.tint }]}>
218
+                {item.name}
219
+              </ThemedText>
220
+              <ThemedText style={styles.badge}>
221
+                {isDone ? t('tasks.done') : t('tasks.pending')}
222
+              </ThemedText>
223
+            </ThemedView>
224
+            {item.description ? (
225
+              <ThemedText style={styles.description}>{item.description}</ThemedText>
226
+            ) : null}
227
+            <ThemedText style={styles.photoLabel}>{t('tasks.photo')}</ThemedText>
228
+            {photoByTaskId[item.id] ? (
229
+              <Image
230
+                source={{ uri: photoByTaskId[item.id] }}
231
+                style={styles.photoPreview}
232
+                contentFit="cover"
233
+              />
234
+            ) : null}
235
+            <View style={styles.photoRow}>
236
+              <ThemedButton
237
+                title={t('tasks.pickPhoto')}
238
+                onPress={() => handlePickPhoto(item)}
239
+                variant="secondary"
240
+              />
241
+              <ThemedButton
242
+                title={t('tasks.takePhoto')}
243
+                onPress={() => handleTakePhoto(item)}
244
+                variant="secondary"
245
+              />
246
+            </View>
247
+            <TextInput
248
+              value={notesByTaskId[item.id] ?? ''}
249
+              onChangeText={(value) =>
250
+                setNotesByTaskId((prev) => ({ ...prev, [item.id]: value }))
251
+              }
252
+              placeholder={t('tasks.notePlaceholder')}
253
+              style={[
254
+                styles.input,
255
+                { borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
256
+              ]}
257
+              multiline
258
+            />
259
+            <View style={styles.buttonRow}>
260
+              <ThemedButton
261
+                title={isDone ? t('tasks.undo') : t('tasks.complete')}
262
+                onPress={() => handleComplete(item)}
263
+              />
264
+            </View>
265
+          </ThemedView>
266
+        );
267
+      }}
268
+        ItemSeparatorComponent={() => <View style={styles.separator} />}
269
+        ListHeaderComponent={
270
+        <View>
271
+          <ThemedView style={styles.hero}>
272
+            <Image source={require('@/assets/images/dailytask.jpg')} style={styles.heroImage} />
273
+          </ThemedView>
274
+          <ThemedView style={styles.titleContainer}>
275
+            <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
276
+              {t('tasks.title')}
277
+            </ThemedText>
278
+            <ThemedText style={styles.subtitle}>{t('tasks.subtitle')}</ThemedText>
279
+            <Link href="/task-history" push>
280
+              <ThemedText type="link">{t('tasks.historyTitle')}</ThemedText>
281
+            </Link>
282
+          </ThemedView>
283
+
284
+          {status ? (
285
+            <ThemedView style={styles.section}>
286
+              <ThemedText>{status}</ThemedText>
287
+            </ThemedView>
288
+          ) : null}
289
+        </View>
290
+      }
291
+        onEndReached={handleLoadMore}
292
+        onEndReachedThreshold={0.4}
293
+        ListFooterComponent={
294
+          <View style={styles.footer}>
295
+            {loadingMore ? <ActivityIndicator /> : null}
296
+          </View>
297
+        }
298
+      />
299
+    </KeyboardAvoidingView>
300
+  );
301
+}
302
+
303
+async function ensureDefaultTasks(
304
+  db: Awaited<typeof dbPromise>,
305
+  tasks: { name: string; description: string; default_time: string }[]
306
+) {
307
+  const row = await db.getFirstAsync<{ count: number }>(
308
+    'SELECT COUNT(*) as count FROM daily_tasks;'
309
+  );
310
+  if (row?.count && row.count > 0) return;
311
+  const now = new Date().toISOString();
312
+  for (const task of tasks) {
313
+    await db.runAsync(
314
+      'INSERT INTO daily_tasks (name, description, default_time, is_active, created_at) VALUES (?, ?, ?, 1, ?);',
315
+      task.name,
316
+      task.description,
317
+      task.default_time,
318
+      now
319
+    );
320
+  }
321
+}
322
+
323
+async function persistTaskPhoto(
324
+  task: TaskRow,
325
+  uri: string,
326
+  setPhotoByTaskId: (photos: Record<number, string>) => void
327
+) {
328
+  setPhotoByTaskId((prev) => ({ ...prev, [task.id]: uri }));
329
+  if (!task.entry_id) return;
330
+  const db = await dbPromise;
331
+  const metaJson = serializeTaskMeta({ photoUri: uri });
332
+  await db.runAsync(
333
+    'UPDATE daily_task_entries SET meta_json = ? WHERE id = ?;',
334
+    metaJson,
335
+    task.entry_id
336
+  );
337
+}
338
+
339
+function parseTaskMeta(raw: string | null) {
340
+  if (!raw) return {} as { photoUri?: string };
341
+  try {
342
+    return JSON.parse(raw) as { photoUri?: string };
343
+  } catch {
344
+    return {} as { photoUri?: string };
345
+  }
346
+}
347
+
348
+function serializeTaskMeta(meta: { photoUri?: string }) {
349
+  if (!meta.photoUri) return null;
350
+  return JSON.stringify(meta);
351
+}
352
+
353
+function getImageMediaTypes() {
354
+  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
355
+    .MediaType;
356
+  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
357
+}
358
+
359
+async function normalizeImageUri(uri: string) {
360
+  try {
361
+    const result = await ImageManipulator.manipulateAsync(
362
+      uri,
363
+      [],
364
+      { compress: 1, format: ImageManipulator.SaveFormat.JPEG }
365
+    );
366
+    return result.uri;
367
+  } catch {
368
+    return uri;
369
+  }
370
+}
371
+
372
+const styles = StyleSheet.create({
373
+  hero: {
374
+    backgroundColor: '#E8E6DA',
375
+    aspectRatio: 16 / 9,
376
+    width: '100%',
377
+  },
378
+  heroImage: {
379
+    width: '100%',
380
+    height: '100%',
381
+  },
382
+  titleContainer: {
383
+    gap: 8,
384
+    paddingHorizontal: 16,
385
+    paddingVertical: 12,
386
+  },
387
+  subtitle: {
388
+    opacity: 0.7,
389
+  },
390
+  section: {
391
+    gap: 8,
392
+    marginBottom: 16,
393
+    paddingHorizontal: 16,
394
+  },
395
+  card: {
396
+    borderRadius: 12,
397
+    borderWidth: 1,
398
+    borderColor: '#C6C6C6',
399
+    padding: 12,
400
+    marginHorizontal: 16,
401
+    marginBottom: 12,
402
+    gap: 8,
403
+    backgroundColor: '#FFFFFF',
404
+  },
405
+  cardHeader: {
406
+    flexDirection: 'row',
407
+    justifyContent: 'space-between',
408
+    gap: 8,
409
+  },
410
+  description: {
411
+    opacity: 0.8,
412
+  },
413
+  photoLabel: {
414
+    opacity: 0.7,
415
+  },
416
+  photoPreview: {
417
+    height: 160,
418
+    width: '100%',
419
+    borderRadius: 12,
420
+  },
421
+  photoRow: {
422
+    flexDirection: 'row',
423
+    gap: 8,
424
+  },
425
+  badge: {
426
+    fontSize: 12,
427
+    opacity: 0.7,
428
+  },
429
+  taskTitle: {
430
+    color: '#2F7D4F',
431
+  },
432
+  input: {
433
+    borderRadius: 10,
434
+    borderWidth: 1,
435
+    borderColor: '#B9B9B9',
436
+    paddingHorizontal: 12,
437
+    paddingVertical: 10,
438
+    fontSize: 15,
439
+  },
440
+  buttonRow: {
441
+    alignSelf: 'flex-start',
442
+  },
443
+  separator: {
444
+    height: 12,
445
+  },
446
+  footer: {
447
+    height: 24,
448
+  },
449
+  keyboardAvoid: {
450
+    flex: 1,
451
+  },
452
+  listContent: {
453
+    paddingBottom: 80,
454
+  },
455
+});

+ 17 - 0
assets/class_names.json

@@ -0,0 +1,17 @@
1
+[
2
+  "Pepper__bell___Bacterial_spot",
3
+  "Pepper__bell___healthy",
4
+  "Potato___Early_blight",
5
+  "Potato___Late_blight",
6
+  "Potato___healthy",
7
+  "Tomato_Bacterial_spot",
8
+  "Tomato_Early_blight",
9
+  "Tomato_Late_blight",
10
+  "Tomato_Leaf_Mold",
11
+  "Tomato_Septoria_leaf_spot",
12
+  "Tomato_Spider_mites_Two_spotted_spider_mite",
13
+  "Tomato__Target_Spot",
14
+  "Tomato__Tomato_YellowLeaf__Curl_Virus",
15
+  "Tomato__Tomato_mosaic_virus",
16
+  "Tomato_healthy"
17
+]

BIN
assets/images/adaptive-icon copy.png


BIN
assets/images/adaptive-icon.png


BIN
assets/images/android-icon-background.png


BIN
assets/images/android-icon-foreground.png


BIN
assets/images/android-icon-monochrome.png


BIN
assets/images/blogpost.jpg


BIN
assets/images/costrecords.jpg


BIN
assets/images/crops.jpg


BIN
assets/images/dailytask.jpg


BIN
assets/images/favicon.png


BIN
assets/images/fields.jpg


BIN
assets/images/harvestrecord.jpg


BIN
assets/images/home.jpg


BIN
assets/images/icon.png


BIN
assets/images/leafscan.jpg


BIN
assets/images/logbook.jpg


BIN
assets/images/observations.jpg


BIN
assets/images/partial-react-logo.png


BIN
assets/images/profile.jpg


BIN
assets/images/react-logo.png


BIN
assets/images/react-logo@2x.png


BIN
assets/images/react-logo@3x.png


BIN
assets/images/salerecords.jpg


BIN
assets/images/splash-icon.png


BIN
assets/images/splash-sf.jpg


BIN
assets/images/userprofile.jpg


BIN
assets/models/tinyyolov2-8.onnx


BIN
assets/plantvillage_mnv3s_224.onnx


BIN
assets/plantvillage_mnv3s_best.pth


+ 25 - 0
components/external-link.tsx

@@ -0,0 +1,25 @@
1
+import { Href, Link } from 'expo-router';
2
+import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
3
+import { type ComponentProps } from 'react';
4
+
5
+type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
6
+
7
+export function ExternalLink({ href, ...rest }: Props) {
8
+  return (
9
+    <Link
10
+      target="_blank"
11
+      {...rest}
12
+      href={href}
13
+      onPress={async (event) => {
14
+        if (process.env.EXPO_OS !== 'web') {
15
+          // Prevent the default behavior of linking to the default browser on native.
16
+          event.preventDefault();
17
+          // Open the link in an in-app browser.
18
+          await openBrowserAsync(href, {
19
+            presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
20
+          });
21
+        }
22
+      }}
23
+    />
24
+  );
25
+}

+ 18 - 0
components/haptic-tab.tsx

@@ -0,0 +1,18 @@
1
+import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
2
+import { PlatformPressable } from '@react-navigation/elements';
3
+import * as Haptics from 'expo-haptics';
4
+
5
+export function HapticTab(props: BottomTabBarButtonProps) {
6
+  return (
7
+    <PlatformPressable
8
+      {...props}
9
+      onPressIn={(ev) => {
10
+        if (process.env.EXPO_OS === 'ios') {
11
+          // Add a soft haptic feedback when pressing down on the tabs.
12
+          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
13
+        }
14
+        props.onPressIn?.(ev);
15
+      }}
16
+    />
17
+  );
18
+}

+ 0 - 0
components/hello-wave.tsx


Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels

tum/network_report_server - Gogs: Simplico Git Service

1 Commits (8ecbc0c489f48b8e055b4c7f3f94d58750869b00)

Auteur SHA1 Bericht Datum
  tum 30f7226d9a first commit 2 jaren geleden