diff --git a/mobile/README.md b/mobile/README.md
index 7576278c..b9912019 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -8,7 +8,7 @@ Flutter mobile application for Android and iOS that integrates with the TimeTrac
- π **Projects & Tasks** - View and select projects and tasks
- π **Time Entries** - View and manage time entries with calendar
- π **Offline Support** - Work offline with automatic sync
-- π **Secure Authentication** - Token-based authentication with secure storage
+- π **Secure Authentication** - Sign in with your web username and password; the app obtains an API token in the background for the same basics access as the web app
## Setup
@@ -36,45 +36,30 @@ flutter run
## Configuration
-### Getting an API Token
+### Signing in
-Before connecting the mobile app, you need to create an API token:
-
-1. **Log in to TimeTracker Web App** as an administrator
-2. Navigate to **Admin > API Tokens** (`/admin/api-tokens`)
-3. Click **"Create Token"**
-4. Fill in the required information:
- - **Name**: A descriptive name (e.g., "Mobile App - John")
- - **User**: Select the user this token will authenticate as
- - **Scopes**: Select the following permissions:
- - `read:projects` - View projects
- - `read:tasks` - View tasks
- - `read:time_entries` - View time entries
- - `write:time_entries` - Create and update time entries
- - **Expires In**: Optional expiration period (leave empty for no expiration)
-5. Click **"Create Token"**
-6. **Important**: Copy the generated token immediately - you won't be able to see it again!
- - Token format: `tt_<32_random_characters>`
- - Example: `tt_abc123def456ghi789jkl012mno345pq`
-
-### Connecting the App
+Use the same **username and password** you use to log in to the TimeTracker web app. The mobile app signs you in via the API and obtains an API token in the background, giving you the same basics access (timer, time entries, projects, tasks) as on the web.
1. **Launch the app** on your device
2. On the login screen, enter:
- - **Server URL**: Your TimeTracker server URL (e.g., `https://your-server.com`)
- - Do not include a trailing slash
- - Use `http://` for local development or `https://` for production
- - **API Token**: Paste the token you copied from the web app
+ - **Server URL**: The base URL of your TimeTracker server (e.g., `https://your-server.com`). Use HTTPS if your server uses SSL.
+ - **Username**: Your web login username
+ - **Password**: Your web login password
3. Tap **"Login"**
-4. The app will validate your connection and navigate to the timer screen if successful
+4. The app will validate your credentials and navigate to the home screen if successful
+
+### Server URL and HTTPS
+
+The default TimeTracker deployment uses **docker-compose** with **NGINX** on ports 80 and 443 (HTTPS). Use your serverβs HTTPS URL (e.g. `https://your-server.com`) with no port unless you use a custom one.
+
+- **Production:** Use a valid certificate (e.g. Letβs Encrypt) so the app can connect without certificate errors.
+- **Local / testing:** Use a trusted CA (e.g. [mkcert](https://github.com/FiloSottile/mkcert)) for HTTPS, or HTTP only if your setup serves the API over HTTP (e.g. dev without NGINX).
### Troubleshooting
-**"Invalid API token" error:**
-- Verify the token starts with `tt_`
-- Check that the token hasn't expired
-- Ensure the token has the required scopes
-- Try creating a new token in the web app
+**"Invalid username or password" error:**
+- Use the same username and password you use on the web app
+- Ensure the server URL is correct and the server is reachable
**"Connection failed" error:**
- Verify the server URL is correct and accessible
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 14d903f1..9bab837c 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -1,3 +1,9 @@
+plugins {
+ id "com.android.application"
+ id "org.jetbrains.kotlin.android"
+ id "dev.flutter.flutter-gradle-plugin"
+}
+
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
@@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) {
}
}
-def flutterRoot = localProperties.getProperty('flutter.sdk')
-if (flutterRoot == null) {
- throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
-}
-
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
@@ -21,22 +22,19 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
-
android {
namespace "com.timetracker.mobile"
- compileSdkVersion 34
+ compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ coreLibraryDesugaringEnabled true
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = '1.8'
+ jvmTarget = '17'
}
sourceSets {
@@ -45,8 +43,8 @@ android {
defaultConfig {
applicationId "com.timetracker.mobile"
- minSdkVersion 21
- targetSdkVersion 34
+ minSdkVersion flutter.minSdkVersion
+ targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
@@ -63,5 +61,6 @@ flutter {
}
dependencies {
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
}
+
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index e988f0f4..41fb56fc 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -2,10 +2,14 @@
package="com.timetracker.mobile">
+
+ android:name="${applicationName}"
+ android:icon="@mipmap/ic_launcher"
+ android:roundIcon="@mipmap/ic_launcher"
+ android:usesCleartextTraffic="true">
\(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/mobile/android/gradlew.bat b/mobile/android/gradlew.bat
new file mode 100644
index 00000000..8a0b282a
--- /dev/null
+++ b/mobile/android/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/mobile/android/local.properties b/mobile/android/local.properties
index 958ca3c1..929c6902 100644
--- a/mobile/android/local.properties
+++ b/mobile/android/local.properties
@@ -1,2 +1,5 @@
-flutter.versionCode=41500
-flutter.versionName=4.15.0
+flutter.versionCode=1
+flutter.versionName=4.15.1
+flutter.sdk=C:\\Flutter\\flutter
+sdk.dir=C:\\Users\\dries\\AppData\\Local\\Android\\Sdk
+flutter.buildMode=release
\ No newline at end of file
diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle
index 44e62bcf..7ede3750 100644
--- a/mobile/android/settings.gradle
+++ b/mobile/android/settings.gradle
@@ -1,11 +1,22 @@
-include ':app'
+pluginManagement {
+ def flutterSdkPath = {
+ def properties = new Properties()
+ file("local.properties").withInputStream { properties.load(it) }
+ def flutterSdkPath = properties.getProperty("flutter.sdk")
+ assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+ return flutterSdkPath
+ }()
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
-def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
-def properties = new Properties()
-
-assert localPropertiesFile.exists()
-localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
-
-def flutterSdkPath = properties.getProperty("flutter.sdk")
-assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
-apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+ id "com.android.application" version "8.6.0" apply false
+ id "org.jetbrains.kotlin.android" version "2.1.0" apply false
+}
+include ":app"
diff --git a/mobile/assets/icon/README.md b/mobile/assets/icon/README.md
new file mode 100644
index 00000000..639075c1
--- /dev/null
+++ b/mobile/assets/icon/README.md
@@ -0,0 +1,23 @@
+# App icon source
+
+The launcher icon is generated at build time from `app_icon.png` (1024Γ1024) by `flutter_launcher_icons`.
+
+## Creating or updating `app_icon.png`
+
+Export the TimeTracker icon from the web app assets:
+
+- **Source:** `app/static/images/timetracker-logo-icon.svg` (project root)
+- **Size:** 1024Γ1024 pixels, PNG
+
+You can export once using:
+
+- **ImageMagick:** `magick ../../app/static/images/timetracker-logo-icon.svg -resize 1024x1024 app_icon.png`
+- **Inkscape:** Export as PNG at 1024Γ1024 from the SVG.
+- **Browser:** Open the SVG, use dev tools or a screenshot tool at 1024Γ1024.
+
+Alternatively, run the project script from the repo root:
+
+- Windows: `scripts\generate-mobile-icon.bat`
+- Linux/macOS: `./scripts/generate-mobile-icon.sh`
+
+(Requires ImageMagick or Inkscape to be installed.)
diff --git a/mobile/assets/icon/app_icon.png b/mobile/assets/icon/app_icon.png
new file mode 100644
index 00000000..8f1e5b02
Binary files /dev/null and b/mobile/assets/icon/app_icon.png differ
diff --git a/mobile/flutter_launcher_icons_ios.yaml b/mobile/flutter_launcher_icons_ios.yaml
new file mode 100644
index 00000000..f0325439
--- /dev/null
+++ b/mobile/flutter_launcher_icons_ios.yaml
@@ -0,0 +1,5 @@
+# Used in CI for iOS build (run after flutter create --platforms=ios)
+flutter_launcher_icons:
+ android: false
+ ios: true
+ image_path: "assets/icon/app_icon.png"
diff --git a/mobile/ios/Flutter/Generated.xcconfig b/mobile/ios/Flutter/Generated.xcconfig
new file mode 100644
index 00000000..b13b7642
--- /dev/null
+++ b/mobile/ios/Flutter/Generated.xcconfig
@@ -0,0 +1,14 @@
+// This is a generated file; do not edit or check into version control.
+FLUTTER_ROOT=C:\Flutter\flutter
+FLUTTER_APPLICATION_PATH=C:\Users\dries\OneDrive\Dokumente\GitHub\TimeTracker\mobile
+COCOAPODS_PARALLEL_CODE_SIGN=true
+FLUTTER_TARGET=lib\main.dart
+FLUTTER_BUILD_DIR=build
+FLUTTER_BUILD_NAME=4.15.1
+FLUTTER_BUILD_NUMBER=1
+EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
+EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
+DART_OBFUSCATION=false
+TRACK_WIDGET_CREATION=true
+TREE_SHAKE_ICONS=false
+PACKAGE_CONFIG=.dart_tool/package_config.json
diff --git a/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py b/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
new file mode 100644
index 00000000..a88caf99
--- /dev/null
+++ b/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
@@ -0,0 +1,32 @@
+#
+# Generated file, do not edit.
+#
+
+import lldb
+
+def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
+ """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
+ base = frame.register["x0"].GetValueAsAddress()
+ page_len = frame.register["x1"].GetValueAsUnsigned()
+
+ # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
+ # first page to see if handled it correctly. This makes diagnosing
+ # misconfiguration (e.g. missing breakpoint) easier.
+ data = bytearray(page_len)
+ data[0:8] = b'IHELPED!'
+
+ error = lldb.SBError()
+ frame.GetThread().GetProcess().WriteMemory(base, data, error)
+ if not error.Success():
+ print(f'Failed to write into {base}[+{page_len}]', error)
+ return
+
+def __lldb_init_module(debugger: lldb.SBDebugger, _):
+ target = debugger.GetDummyTarget()
+ # Caveat: must use BreakpointCreateByRegEx here and not
+ # BreakpointCreateByName. For some reasons callback function does not
+ # get carried over from dummy target for the later.
+ bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
+ bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
+ bp.SetAutoContinue(True)
+ print("-- LLDB integration loaded --")
diff --git a/mobile/ios/Flutter/ephemeral/flutter_lldbinit b/mobile/ios/Flutter/ephemeral/flutter_lldbinit
new file mode 100644
index 00000000..e3ba6fbe
--- /dev/null
+++ b/mobile/ios/Flutter/ephemeral/flutter_lldbinit
@@ -0,0 +1,5 @@
+#
+# Generated file, do not edit.
+#
+
+command script import --relative-to-command-file flutter_lldb_helper.py
diff --git a/mobile/ios/Flutter/flutter_export_environment.sh b/mobile/ios/Flutter/flutter_export_environment.sh
new file mode 100644
index 00000000..2198fb62
--- /dev/null
+++ b/mobile/ios/Flutter/flutter_export_environment.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# This is a generated file; do not edit or check into version control.
+export "FLUTTER_ROOT=C:\Flutter\flutter"
+export "FLUTTER_APPLICATION_PATH=C:\Users\dries\OneDrive\Dokumente\GitHub\TimeTracker\mobile"
+export "COCOAPODS_PARALLEL_CODE_SIGN=true"
+export "FLUTTER_TARGET=lib\main.dart"
+export "FLUTTER_BUILD_DIR=build"
+export "FLUTTER_BUILD_NAME=4.15.1"
+export "FLUTTER_BUILD_NUMBER=1"
+export "DART_OBFUSCATION=false"
+export "TRACK_WIDGET_CREATION=true"
+export "TREE_SHAKE_ICONS=false"
+export "PACKAGE_CONFIG=.dart_tool/package_config.json"
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..fc612fde
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,116 @@
+{
+ "images" : [
+ {
+ "filename" : "Icon-App-20x20@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-App-20x20@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-App-29x29@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-App-29x29@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-App-40x40@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-App-40x40@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-App-60x60@2x.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "Icon-App-60x60@3x.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "Icon-App-20x20@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-App-20x20@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "Icon-App-29x29@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-App-29x29@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "Icon-App-40x40@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-App-40x40@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "Icon-App-76x76@1x.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "Icon-App-76x76@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 00000000..e69de29b
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 00000000..c2d98015
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 00000000..df931962
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 00000000..7d3dc140
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 00000000..f930fcff
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 00000000..548bde90
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 00000000..b25d703b
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 00000000..df931962
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 00000000..ef9a2b19
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 00000000..e6bf9d6f
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 00000000..50aea291
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 00000000..272123cb
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 00000000..a8460967
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 00000000..1752ff59
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 00000000..e6bf9d6f
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 00000000..741534db
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 00000000..236ba8e6
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 00000000..0a00012e
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 00000000..eccc554d
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 00000000..c82383a6
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 00000000..0eabf389
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/Contents.json b/mobile/ios/Runner/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.h b/mobile/ios/Runner/GeneratedPluginRegistrant.h
new file mode 100644
index 00000000..7a890927
--- /dev/null
+++ b/mobile/ios/Runner/GeneratedPluginRegistrant.h
@@ -0,0 +1,19 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GeneratedPluginRegistrant_h
+#define GeneratedPluginRegistrant_h
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface GeneratedPluginRegistrant : NSObject
++ (void)registerWithRegistry:(NSObject*)registry;
+@end
+
+NS_ASSUME_NONNULL_END
+#endif /* GeneratedPluginRegistrant_h */
diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m
new file mode 100644
index 00000000..59340944
--- /dev/null
+++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m
@@ -0,0 +1,63 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#import "GeneratedPluginRegistrant.h"
+
+#if __has_include()
+#import
+#else
+@import connectivity_plus;
+#endif
+
+#if __has_include()
+#import
+#else
+@import flutter_local_notifications;
+#endif
+
+#if __has_include()
+#import
+#else
+@import flutter_secure_storage;
+#endif
+
+#if __has_include()
+#import
+#else
+@import package_info_plus;
+#endif
+
+#if __has_include()
+#import
+#else
+@import permission_handler_apple;
+#endif
+
+#if __has_include()
+#import
+#else
+@import shared_preferences_foundation;
+#endif
+
+#if __has_include()
+#import
+#else
+@import workmanager_apple;
+#endif
+
+@implementation GeneratedPluginRegistrant
+
++ (void)registerWithRegistry:(NSObject*)registry {
+ [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]];
+ [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
+ [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]];
+ [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
+ [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
+ [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
+ [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]];
+}
+
+@end
diff --git a/mobile/lib/core/config/app_config.dart b/mobile/lib/core/config/app_config.dart
index cfcae4a6..65bb58e3 100644
--- a/mobile/lib/core/config/app_config.dart
+++ b/mobile/lib/core/config/app_config.dart
@@ -8,6 +8,7 @@ class AppConfig {
static const String syncIntervalKey = 'sync_interval';
static const String autoSyncKey = 'auto_sync';
static const String themeModeKey = 'theme_mode';
+ static const String trustedInsecureHostsKey = 'trusted_insecure_hosts';
static const _storage = FlutterSecureStorage();
/// Get server URL from storage (synchronous getter for splash screen)
@@ -63,12 +64,51 @@ class AppConfig {
await prefs.remove(serverUrlKey);
}
+ /// Get auto sync setting
+ static Future getAutoSync() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(autoSyncKey) ?? true;
+ }
+
/// Set auto sync setting
static Future setAutoSync(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(autoSyncKey, value);
}
+ /// Get sync interval (seconds)
+ static Future getSyncInterval() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getInt(syncIntervalKey) ?? 60;
+ }
+
+ /// Get theme mode
+ static Future getThemeMode() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getString(themeModeKey) ?? 'system';
+ }
+
+ /// Set theme mode ('system', 'light', 'dark')
+ static Future setThemeMode(String value) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(themeModeKey, value);
+ }
+
+ /// Get set of host names the user has chosen to trust (self-signed/invalid certs)
+ static Future> getTrustedInsecureHosts() async {
+ final prefs = await SharedPreferences.getInstance();
+ final list = prefs.getStringList(trustedInsecureHostsKey);
+ return list != null ? list.toSet() : {};
+ }
+
+ /// Add a host to the trusted insecure hosts set (user accepted the cert)
+ static Future addTrustedInsecureHost(String host) async {
+ final prefs = await SharedPreferences.getInstance();
+ final set = await getTrustedInsecureHosts();
+ set.add(host);
+ await prefs.setStringList(trustedInsecureHostsKey, set.toList());
+ }
+
/// Clear all stored configuration
static Future clear() async {
final prefs = await SharedPreferences.getInstance();
@@ -76,6 +116,7 @@ class AppConfig {
await prefs.remove(syncIntervalKey);
await prefs.remove(autoSyncKey);
await prefs.remove(themeModeKey);
+ await prefs.remove(trustedInsecureHostsKey);
await _storage.delete(key: apiTokenKey);
}
diff --git a/mobile/lib/core/theme/app_theme.dart b/mobile/lib/core/theme/app_theme.dart
index cc585b0c..a4522d8b 100644
--- a/mobile/lib/core/theme/app_theme.dart
+++ b/mobile/lib/core/theme/app_theme.dart
@@ -1,19 +1,102 @@
import 'package:flutter/material.dart';
+/// TimeTracker brand colors aligned with the webapp (brand-colors.css / tailwind.config.js).
+class AppColors {
+ // Primary & secondary (webapp)
+ static const Color primary = Color(0xFF4A90E2);
+ static const Color primaryDark = Color(0xFF3B82F6);
+ static const Color secondary = Color(0xFF50E3C2);
+ static const Color secondaryDark = Color(0xFF06B6D4);
+
+ // Light mode (webapp)
+ static const Color bgLight = Color(0xFFF7F9FB);
+ static const Color bgLightSecondary = Color(0xFFFFFFFF);
+ static const Color textLight = Color(0xFF2D3748);
+ static const Color textLightSecondary = Color(0xFFA0AEC0);
+ static const Color textLightMuted = Color(0xFF718096);
+ static const Color borderLight = Color(0xFFE2E8F0);
+
+ // Dark mode (webapp)
+ static const Color bgDark = Color(0xFF1A202C);
+ static const Color bgDarkSecondary = Color(0xFF2D3748);
+ static const Color textDark = Color(0xFFE2E8F0);
+ static const Color textDarkSecondary = Color(0xFF718096);
+ static const Color textDarkMuted = Color(0xFFA0AEC0);
+ static const Color borderDark = Color(0xFF4A5568);
+
+ // Status (webapp)
+ static const Color success = Color(0xFF4CAF50);
+ static const Color warning = Color(0xFFFF9800);
+ static const Color error = Color(0xFFE53935);
+ static const Color info = Color(0xFF2196F3);
+}
+
class AppTheme {
+ /// Light color scheme matching the webapp (brand-colors.css, tailwind).
+ static ColorScheme get _lightColorScheme => ColorScheme.light(
+ primary: AppColors.primary,
+ onPrimary: Colors.white,
+ primaryContainer: AppColors.primary.withValues(alpha: 0.2),
+ onPrimaryContainer: AppColors.primary,
+ secondary: AppColors.secondary,
+ onSecondary: AppColors.textLight,
+ secondaryContainer: AppColors.secondary.withValues(alpha: 0.3),
+ onSecondaryContainer: AppColors.textLight,
+ tertiary: AppColors.secondaryDark,
+ onTertiary: Colors.white,
+ tertiaryContainer: AppColors.secondaryDark.withValues(alpha: 0.25),
+ onTertiaryContainer: AppColors.textLight,
+ error: AppColors.error,
+ onError: Colors.white,
+ errorContainer: AppColors.error.withValues(alpha: 0.15),
+ onErrorContainer: AppColors.error,
+ surface: AppColors.bgLight,
+ onSurface: AppColors.textLight,
+ onSurfaceVariant: AppColors.textLightMuted,
+ outline: AppColors.borderLight,
+ outlineVariant: AppColors.borderLight.withValues(alpha: 0.6),
+ surfaceContainerHighest: AppColors.bgLightSecondary,
+ surfaceContainerLowest: AppColors.bgLightSecondary,
+ );
+
+ /// Dark color scheme matching the webapp dark mode.
+ static ColorScheme get _darkColorScheme => ColorScheme.dark(
+ primary: AppColors.primary,
+ onPrimary: Colors.white,
+ primaryContainer: AppColors.primary.withValues(alpha: 0.3),
+ onPrimaryContainer: AppColors.textDark,
+ secondary: AppColors.secondary,
+ onSecondary: AppColors.textDark,
+ secondaryContainer: AppColors.secondary.withValues(alpha: 0.25),
+ onSecondaryContainer: AppColors.textDark,
+ tertiary: AppColors.secondaryDark,
+ onTertiary: Colors.white,
+ tertiaryContainer: AppColors.secondaryDark.withValues(alpha: 0.3),
+ onTertiaryContainer: AppColors.textDark,
+ error: AppColors.error,
+ onError: Colors.white,
+ errorContainer: AppColors.error.withValues(alpha: 0.25),
+ onErrorContainer: const Color(0xFFFCA5A5),
+ surface: AppColors.bgDark,
+ onSurface: AppColors.textDark,
+ onSurfaceVariant: AppColors.textDarkSecondary,
+ outline: AppColors.borderDark,
+ outlineVariant: AppColors.borderDark.withValues(alpha: 0.6),
+ surfaceContainerHighest: AppColors.bgDarkSecondary,
+ surfaceContainerLowest: AppColors.bgDarkSecondary,
+ );
+
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
- colorScheme: ColorScheme.fromSeed(
- seedColor: const Color(0xFF2196F3),
- brightness: Brightness.light,
- ),
+ colorScheme: _lightColorScheme,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
- cardTheme: CardTheme(
+ cardTheme: CardThemeData(
elevation: 2,
+ color: AppColors.bgLightSecondary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -26,22 +109,52 @@ class AppTheme {
),
),
),
+ floatingActionButtonTheme: FloatingActionButtonThemeData(
+ backgroundColor: _lightColorScheme.primary,
+ foregroundColor: _lightColorScheme.onPrimary,
+ elevation: 2,
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ ),
+ snackBarTheme: SnackBarThemeData(
+ behavior: SnackBarBehavior.floating,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ contentTextStyle: const TextStyle(fontSize: 14),
+ ),
+ dialogTheme: DialogThemeData(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ titleTextStyle: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ listTileTheme: const ListTileThemeData(
+ contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ minLeadingWidth: 40,
+ ),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
- colorScheme: ColorScheme.fromSeed(
- seedColor: const Color(0xFF2196F3),
- brightness: Brightness.dark,
- ),
+ colorScheme: _darkColorScheme,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
- cardTheme: CardTheme(
+ cardTheme: CardThemeData(
elevation: 2,
+ color: AppColors.bgDarkSecondary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -54,6 +167,36 @@ class AppTheme {
),
),
),
+ floatingActionButtonTheme: const FloatingActionButtonThemeData(
+ elevation: 2,
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ ),
+ snackBarTheme: SnackBarThemeData(
+ behavior: SnackBarBehavior.floating,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ contentTextStyle: const TextStyle(fontSize: 14),
+ ),
+ dialogTheme: DialogThemeData(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ titleTextStyle: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ listTileTheme: const ListTileThemeData(
+ contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+ minLeadingWidth: 40,
+ ),
);
}
}
diff --git a/mobile/lib/core/themes/app_theme.dart b/mobile/lib/core/themes/app_theme.dart
deleted file mode 100644
index fb4a2315..00000000
--- a/mobile/lib/core/themes/app_theme.dart
+++ /dev/null
@@ -1,76 +0,0 @@
-import 'package:flutter/material.dart';
-
-class AppTheme {
- // Color Scheme
- static const Color primaryColor = Color(0xFF2196F3);
- static const Color secondaryColor = Color(0xFF03A9F4);
- static const Color errorColor = Color(0xFFE53935);
- static const Color successColor = Color(0xFF4CAF50);
- static const Color warningColor = Color(0xFFFF9800);
-
- // Light Theme
- static ThemeData lightTheme = ThemeData(
- useMaterial3: true,
- colorScheme: ColorScheme.fromSeed(
- seedColor: primaryColor,
- brightness: Brightness.light,
- ),
- appBarTheme: const AppBarTheme(
- centerTitle: true,
- elevation: 0,
- ),
- cardTheme: CardTheme(
- elevation: 2,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12),
- ),
- ),
- inputDecorationTheme: InputDecorationTheme(
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
- ),
- filled: true,
- ),
- elevatedButtonTheme: ElevatedButtonThemeData(
- style: ElevatedButton.styleFrom(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8),
- ),
- ),
- ),
- );
-
- // Dark Theme
- static ThemeData darkTheme = ThemeData(
- useMaterial3: true,
- colorScheme: ColorScheme.fromSeed(
- seedColor: primaryColor,
- brightness: Brightness.dark,
- ),
- appBarTheme: const AppBarTheme(
- centerTitle: true,
- elevation: 0,
- ),
- cardTheme: CardTheme(
- elevation: 2,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12),
- ),
- ),
- inputDecorationTheme: InputDecorationTheme(
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
- ),
- filled: true,
- ),
- elevatedButtonTheme: ElevatedButtonThemeData(
- style: ElevatedButton.styleFrom(
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8),
- ),
- ),
- ),
- );
-}
diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart
index e3d1b169..61086338 100644
--- a/mobile/lib/data/api/api_client.dart
+++ b/mobile/lib/data/api/api_client.dart
@@ -1,22 +1,26 @@
import 'package:dio/dio.dart';
+import 'package:timetracker_mobile/utils/ssl/ssl_utils.dart';
+
class ApiClient {
final String baseUrl;
late final Dio _dio;
- String? _authToken;
- ApiClient({required this.baseUrl}) {
+ ApiClient({
+ required String baseUrl,
+ Set trustedInsecureHosts = const {},
+ }) : baseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/' {
_dio = Dio(BaseOptions(
- baseUrl: baseUrl,
+ baseUrl: this.baseUrl,
headers: {
'Content-Type': 'application/json',
},
));
+ configureDioTrustedHosts(_dio, trustedInsecureHosts);
}
/// Set authentication token
Future setAuthToken(String token) async {
- _authToken = token;
_dio.options.headers['Authorization'] = 'Bearer $token';
}
diff --git a/mobile/lib/data/local/background/workmanager_handler.dart b/mobile/lib/data/local/background/workmanager_handler.dart
index dd7fbd0b..56254374 100644
--- a/mobile/lib/data/local/background/workmanager_handler.dart
+++ b/mobile/lib/data/local/background/workmanager_handler.dart
@@ -1,9 +1,7 @@
import 'package:workmanager/workmanager.dart';
import '../../api/api_client.dart';
-import '../../models/time_entry.dart';
import '../../../core/config/app_config.dart';
import '../../../utils/auth/auth_service.dart';
-import 'dart:convert';
@pragma('vm:entry-point')
void callbackDispatcher() {
@@ -35,8 +33,8 @@ Future _updateTimerStatus() async {
final apiClient = ApiClient(baseUrl: serverUrl);
await apiClient.setAuthToken(token);
- final response = await apiClient.getTimerStatus();
- if (response.statusCode == 200 && response.data['active'] == true) {
+ final data = await apiClient.getTimerStatus();
+ if (data['active'] == true) {
// Timer is still running, could update local notification
return true;
}
@@ -74,7 +72,7 @@ Future _syncData() async {
class WorkManagerService {
static Future initialize() async {
- await Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
+ await Workmanager().initialize(callbackDispatcher);
}
static Future startTimerStatusUpdates() async {
diff --git a/mobile/lib/data/local/database/sync_service.dart b/mobile/lib/data/local/database/sync_service.dart
index 0c40053a..9fe230ba 100644
--- a/mobile/lib/data/local/database/sync_service.dart
+++ b/mobile/lib/data/local/database/sync_service.dart
@@ -1,9 +1,9 @@
-import '../../../core/constants/app_constants.dart';
+import 'dart:convert';
+
import '../database/hive_service.dart';
import '../../api/api_client.dart';
import '../../models/time_entry.dart';
import '../../models/project.dart';
-import '../../models/task.dart';
class SyncQueueItem {
final String id;
@@ -96,15 +96,33 @@ class SyncService {
}
Future _syncTimeEntry(SyncQueueItem item) async {
+ final d = item.data;
switch (item.action) {
case 'create':
- await apiClient.createTimeEntry(item.data);
+ await apiClient.createTimeEntry(
+ projectId: d['project_id'] as int,
+ startTime: d['start_time'] as String,
+ taskId: d['task_id'] as int?,
+ endTime: d['end_time'] as String?,
+ notes: d['notes'] as String?,
+ tags: d['tags'] as String?,
+ billable: d['billable'] as bool?,
+ );
break;
case 'update':
- await apiClient.updateTimeEntry(item.data['id'], item.data);
+ await apiClient.updateTimeEntry(
+ d['id'] as int,
+ projectId: d['project_id'] as int?,
+ taskId: d['task_id'] as int?,
+ startTime: d['start_time'] as String?,
+ endTime: d['end_time'] as String?,
+ notes: d['notes'] as String?,
+ tags: d['tags'] as String?,
+ billable: d['billable'] as bool?,
+ );
break;
case 'delete':
- await apiClient.deleteTimeEntry(item.data['id']);
+ await apiClient.deleteTimeEntry(d['id'] as int);
break;
}
}
@@ -123,13 +141,11 @@ class SyncService {
Future syncFromServer() async {
try {
// Sync projects
- final projectsResponse = await apiClient.getProjects(status: 'active');
- if (projectsResponse.statusCode == 200) {
- final projects = (projectsResponse.data['projects'] as List)
- .map((json) => Project.fromJson(json))
- .toList();
-
- for (final project in projects) {
+ final projectsData = await apiClient.getProjects(status: 'active');
+ final projectsList = projectsData['projects'] as List?;
+ if (projectsList != null) {
+ for (final json in projectsList) {
+ final project = Project.fromJson(Map.from(json as Map));
await HiveService.projectsBox.put(project.id, project.toJson());
}
}
@@ -137,17 +153,14 @@ class SyncService {
// Sync time entries (recent ones)
final now = DateTime.now();
final startDate = now.subtract(const Duration(days: 30));
- final entriesResponse = await apiClient.getTimeEntries(
+ final entriesData = await apiClient.getTimeEntries(
startDate: startDate.toIso8601String().split('T')[0],
endDate: now.toIso8601String().split('T')[0],
);
-
- if (entriesResponse.statusCode == 200) {
- final entries = (entriesResponse.data['time_entries'] as List)
- .map((json) => TimeEntry.fromJson(json))
- .toList();
-
- for (final entry in entries) {
+ final entriesList = entriesData['time_entries'] as List?;
+ if (entriesList != null) {
+ for (final json in entriesList) {
+ final entry = TimeEntry.fromJson(Map.from(json as Map));
await HiveService.timeEntriesBox.put(entry.id, entry.toJson());
}
}
@@ -166,8 +179,8 @@ class SyncService {
if (value is Map) {
return Project.fromJson(Map.from(value));
} else if (value is String) {
- // If stored as string, parse it (though Hive usually stores as Map)
- return Project.fromJson(Map.from(value));
+ return Project.fromJson(
+ Map.from(jsonDecode(value) as Map));
}
throw Exception('Invalid project data format');
})
@@ -189,22 +202,33 @@ class SyncService {
if (value is Map) {
return TimeEntry.fromJson(Map.from(value));
} else if (value is String) {
- return TimeEntry.fromJson(Map.from(value));
+ return TimeEntry.fromJson(
+ Map.from(jsonDecode(value) as Map));
}
throw Exception('Invalid time entry data format');
})
.toList();
- if (startDate != null) {
- entries = entries.where((e) => e.startTime.isAfter(startDate)).toList();
- }
- if (endDate != null) {
- entries = entries.where((e) => e.startTime.isBefore(endDate)).toList();
- }
- if (projectId != null) {
- entries = entries.where((e) => e.projectId == projectId).toList();
- }
+ if (startDate != null) {
+ entries = entries
+ .where((e) =>
+ e.startTime != null && e.startTime!.isAfter(startDate))
+ .toList();
+ }
+ if (endDate != null) {
+ entries = entries
+ .where((e) =>
+ e.startTime != null && e.startTime!.isBefore(endDate))
+ .toList();
+ }
+ if (projectId != null) {
+ entries =
+ entries.where((e) => e.projectId == projectId).toList();
+ }
- return entries;
+ return entries;
+ } catch (e) {
+ return [];
+ }
}
}
diff --git a/mobile/lib/data/models/project.dart b/mobile/lib/data/models/project.dart
index b32f2c62..3f0c05f6 100644
--- a/mobile/lib/data/models/project.dart
+++ b/mobile/lib/data/models/project.dart
@@ -28,4 +28,16 @@ class Project {
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'name': name,
+ 'client': client,
+ 'status': status,
+ 'billable': billable,
+ 'created_at': createdAt.toIso8601String(),
+ 'updated_at': updatedAt.toIso8601String(),
+ };
+ }
}
diff --git a/mobile/lib/domain/repositories/time_tracking_repository.dart b/mobile/lib/domain/repositories/time_tracking_repository.dart
index f97c9053..027f421e 100644
--- a/mobile/lib/domain/repositories/time_tracking_repository.dart
+++ b/mobile/lib/domain/repositories/time_tracking_repository.dart
@@ -1,4 +1,5 @@
import 'package:connectivity_plus/connectivity_plus.dart';
+import 'package:dio/dio.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
import 'package:timetracker_mobile/data/models/timer.dart';
import 'package:timetracker_mobile/data/models/time_entry.dart';
@@ -7,6 +8,14 @@ import 'package:timetracker_mobile/data/models/task.dart';
import 'package:timetracker_mobile/data/storage/local_storage.dart';
import 'package:timetracker_mobile/data/storage/sync_service.dart';
+/// Thrown when stop timer returns 400 because the timer was already stopped
+class TimerAlreadyStoppedException implements Exception {
+ final String message;
+ TimerAlreadyStoppedException(this.message);
+ @override
+ String toString() => message;
+}
+
/// Repository for time tracking operations
class TimeTrackingRepository {
final ApiClient? apiClient;
@@ -106,7 +115,23 @@ class TimeTrackingRepository {
}
try {
final response = await apiClient!.stopTimer();
- return TimeEntry.fromJson(response['time_entry'] as Map);
+ final entry = TimeEntry.fromJson(response['time_entry'] as Map);
+ await LocalStorage.clearTimer();
+ return entry;
+ } on DioException catch (e) {
+ if (e.response?.statusCode == 400) {
+ final data = e.response?.data;
+ final errorCode = data is Map ? data['error_code'] as String? : null;
+ final errorMsg = data is Map ? data['error'] as String? : null;
+ await LocalStorage.clearTimer();
+ if (errorCode == 'no_active_timer' || errorCode == 'timer_already_stopped') {
+ throw TimerAlreadyStoppedException(
+ errorMsg ?? 'Timer was already stopped',
+ );
+ }
+ throw Exception(errorMsg ?? 'Failed to stop timer');
+ }
+ rethrow;
} catch (e) {
throw Exception('Failed to stop timer: $e');
}
diff --git a/mobile/lib/domain/usecases/sync_usecase.dart b/mobile/lib/domain/usecases/sync_usecase.dart
index e6f096a6..0dc22833 100644
--- a/mobile/lib/domain/usecases/sync_usecase.dart
+++ b/mobile/lib/domain/usecases/sync_usecase.dart
@@ -6,20 +6,19 @@ import '../../utils/auth/auth_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
class SyncUseCase {
- final SyncService _syncService;
final Connectivity _connectivity = Connectivity();
Timer? _syncTimer;
- SyncUseCase(this._syncService);
+ SyncUseCase();
// Start periodic sync if auto-sync is enabled
- void startPeriodicSync() {
- if (!AppConfig.autoSync) return;
+ Future startPeriodicSync() async {
+ final autoSync = await AppConfig.getAutoSync();
+ if (!autoSync) return;
_syncTimer?.cancel();
- final interval = Duration(seconds: AppConfig.syncInterval);
-
- _syncTimer = Timer.periodic(interval, (timer) async {
+ final intervalSeconds = await AppConfig.getSyncInterval();
+ _syncTimer = Timer.periodic(Duration(seconds: intervalSeconds), (timer) async {
if (await _isOnline()) {
await sync();
}
@@ -41,11 +40,10 @@ class SyncUseCase {
// Full sync: process queue and sync from server
Future sync() async {
try {
- // Ensure we have API client
- final serverUrl = AppConfig.serverUrl;
+ final serverUrl = await AppConfig.getServerUrl();
final token = await AuthService.getToken();
- if (serverUrl == null || token == null) {
+ if (serverUrl == null || serverUrl.isEmpty || token == null || token.isEmpty) {
return false;
}
@@ -54,15 +52,11 @@ class SyncUseCase {
final syncService = SyncService(apiClient);
- // Process sync queue (offline operations)
await syncService.processQueue();
-
- // Sync from server (get latest data)
await syncService.syncFromServer();
return true;
- } catch (e) {
- print('Sync error: $e');
+ } catch (_) {
return false;
}
}
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 3d842953..04390d39 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/core/constants/app_constants.dart';
+import 'package:timetracker_mobile/core/theme/app_theme.dart';
import 'package:timetracker_mobile/data/storage/local_storage.dart';
+import 'package:timetracker_mobile/presentation/providers/theme_mode_provider.dart';
import 'package:timetracker_mobile/presentation/screens/splash_screen.dart';
import 'package:timetracker_mobile/presentation/screens/login_screen.dart';
import 'package:timetracker_mobile/presentation/screens/home_screen.dart';
@@ -16,17 +18,18 @@ void main() async {
);
}
-class TimeTrackerApp extends StatelessWidget {
+class TimeTrackerApp extends ConsumerWidget {
const TimeTrackerApp({super.key});
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+ final themeModeString = ref.watch(themeModeProvider);
+ final themeMode = themeModeFromString(themeModeString);
return MaterialApp(
title: 'TimeTracker',
- theme: ThemeData(
- colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
- useMaterial3: true,
- ),
+ theme: AppTheme.lightTheme,
+ darkTheme: AppTheme.darkTheme,
+ themeMode: themeMode,
initialRoute: AppConstants.routeSplash,
routes: {
AppConstants.routeSplash: (context) => const SplashScreen(),
diff --git a/mobile/lib/presentation/providers/api_provider.dart b/mobile/lib/presentation/providers/api_provider.dart
index 9d6cc9e8..cb30e036 100644
--- a/mobile/lib/presentation/providers/api_provider.dart
+++ b/mobile/lib/presentation/providers/api_provider.dart
@@ -1,14 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
+import 'package:timetracker_mobile/utils/auth/auth_service.dart';
-/// Provider for API client
+/// Provider for API client (authenticated when token is present)
final apiClientProvider = FutureProvider((ref) async {
final serverUrl = await AppConfig.getServerUrl();
if (serverUrl == null || serverUrl.isEmpty) {
return null;
}
- return ApiClient(baseUrl: serverUrl);
+ final token = await AuthService.getToken();
+ final trustedHosts = await AppConfig.getTrustedInsecureHosts();
+ final client = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts);
+ if (token != null && token.isNotEmpty) {
+ await client.setAuthToken(token);
+ }
+ return client;
});
/// Provider for API client (synchronous, requires server URL)
diff --git a/mobile/lib/presentation/providers/theme_mode_provider.dart b/mobile/lib/presentation/providers/theme_mode_provider.dart
new file mode 100644
index 00000000..1ea074c0
--- /dev/null
+++ b/mobile/lib/presentation/providers/theme_mode_provider.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:timetracker_mobile/core/config/app_config.dart';
+
+/// Notifier that holds the current theme mode string ('system', 'light', 'dark')
+/// and loads the saved value on startup.
+class ThemeModeNotifier extends StateNotifier {
+ ThemeModeNotifier() : super('system') {
+ _load();
+ }
+
+ void _load() {
+ AppConfig.getThemeMode().then((value) {
+ state = value;
+ });
+ }
+
+ Future setMode(String value) async {
+ await AppConfig.setThemeMode(value);
+ state = value;
+ }
+}
+
+final themeModeProvider =
+ StateNotifierProvider((ref) => ThemeModeNotifier());
+
+/// Maps stored string to Flutter's ThemeMode.
+ThemeMode themeModeFromString(String value) {
+ switch (value) {
+ case 'light':
+ return ThemeMode.light;
+ case 'dark':
+ return ThemeMode.dark;
+ default:
+ return ThemeMode.system;
+ }
+}
diff --git a/mobile/lib/presentation/providers/timer_provider.dart b/mobile/lib/presentation/providers/timer_provider.dart
index ecb7e99f..b736ff33 100644
--- a/mobile/lib/presentation/providers/timer_provider.dart
+++ b/mobile/lib/presentation/providers/timer_provider.dart
@@ -29,11 +29,13 @@ class TimerState {
Timer? timer,
bool? isLoading,
String? error,
+ bool clearTimer = false,
+ bool clearError = false,
}) {
return TimerState(
- timer: timer ?? this.timer,
+ timer: clearTimer ? null : (timer ?? this.timer),
isLoading: isLoading ?? this.isLoading,
- error: error,
+ error: clearError ? null : (error ?? this.error),
);
}
@@ -67,7 +69,7 @@ class TimerNotifier extends StateNotifier {
if (repository == null) return;
try {
- state = state.copyWith(isLoading: true, error: null);
+ state = state.copyWith(isLoading: true, clearError: true);
final timer = await repository!.getTimerStatus();
state = state.copyWith(timer: timer, isLoading: false);
} catch (e) {
@@ -86,7 +88,7 @@ class TimerNotifier extends StateNotifier {
}
try {
- state = state.copyWith(isLoading: true, error: null);
+ state = state.copyWith(isLoading: true, clearError: true);
final timer = await repository!.startTimer(
projectId: projectId,
taskId: taskId,
@@ -107,9 +109,11 @@ class TimerNotifier extends StateNotifier {
}
try {
- state = state.copyWith(isLoading: true, error: null);
+ state = state.copyWith(isLoading: true, clearError: true);
await repository!.stopTimer();
- state = state.copyWith(timer: null, isLoading: false);
+ state = state.copyWith(clearTimer: true, isLoading: false, clearError: true);
+ } on TimerAlreadyStoppedException catch (e) {
+ state = state.copyWith(clearTimer: true, isLoading: false, error: e.message);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
@@ -121,7 +125,7 @@ class TimerNotifier extends StateNotifier {
/// Get elapsed time for active timer
Duration getElapsedTime() {
- if (state.timer == null || state.timer!.startTime == null) {
+ if (state.timer == null) {
return Duration.zero;
}
return DateTime.now().difference(state.timer!.startTime);
diff --git a/mobile/lib/presentation/screens/home_screen.dart b/mobile/lib/presentation/screens/home_screen.dart
index bb93f711..d18d93d1 100644
--- a/mobile/lib/presentation/screens/home_screen.dart
+++ b/mobile/lib/presentation/screens/home_screen.dart
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../core/constants/app_constants.dart';
+import 'package:timetracker_mobile/data/models/project.dart';
import '../providers/timer_provider.dart';
import '../providers/time_entries_provider.dart';
+import '../providers/projects_provider.dart';
+import '../widgets/empty_state.dart';
import 'timer_screen.dart';
import 'projects_screen.dart';
import 'time_entries_screen.dart';
@@ -100,6 +102,7 @@ class _DashboardTabState extends ConsumerState {
// Load data on init
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(timerProvider.notifier).checkTimerStatus();
+ ref.read(projectsProvider.notifier).loadProjects();
final now = DateTime.now();
ref.read(timeEntriesProvider.notifier).loadTimeEntries(
startDate: now.toIso8601String().split('T')[0],
@@ -114,10 +117,22 @@ class _DashboardTabState extends ConsumerState {
super.dispose();
}
+ String _projectName(int? projectId, List projects) {
+ if (projectId == null) return 'Unknown project';
+ try {
+ final p = projects.firstWhere((p) => p.id == projectId);
+ return p.name;
+ } catch (_) {
+ return 'Unknown project';
+ }
+ }
+
@override
Widget build(BuildContext context) {
final timerState = ref.watch(timerProvider);
final entriesState = ref.watch(timeEntriesProvider);
+ final projectsState = ref.watch(projectsProvider);
+ final theme = Theme.of(context);
// Calculate today's total
final todayTotal = entriesState.entries.fold(
@@ -173,7 +188,7 @@ class _DashboardTabState extends ConsumerState {
Text(
'Tap to view details',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: Colors.grey,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -182,7 +197,7 @@ class _DashboardTabState extends ConsumerState {
Text(
'No active timer',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
- color: Colors.grey,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -207,8 +222,11 @@ class _DashboardTabState extends ConsumerState {
entriesState.isLoading
? 'Loading...'
: '${hours}h ${minutes}m',
- style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
+ color: entriesState.isLoading
+ ? theme.colorScheme.onSurfaceVariant
+ : null,
),
),
],
@@ -224,24 +242,33 @@ class _DashboardTabState extends ConsumerState {
if (entriesState.isLoading)
const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()))
else if (entriesState.entries.isEmpty)
- const Center(
- child: Padding(
- padding: EdgeInsets.all(32),
- child: Text('No recent entries'),
- ),
+ const EmptyState(
+ icon: Icons.history,
+ title: 'No recent entries',
+ subtitle: 'Start a timer or add a time entry to see them here.',
)
else
- ...entriesState.entries.take(5).map((entry) => Card(
- margin: const EdgeInsets.only(bottom: 8),
- child: ListTile(
- leading: CircleAvatar(
- child: Icon(Icons.timer),
- ),
- title: Text(entry.projectId?.toString() ?? 'Unknown Project'),
- subtitle: Text(entry.notes ?? ''),
- trailing: Text(entry.formattedDuration),
+ ...entriesState.entries.take(5).map((entry) {
+ final projectName = _projectName(entry.projectId, projectsState.projects);
+ return Card(
+ margin: const EdgeInsets.only(bottom: 8),
+ child: ListTile(
+ leading: CircleAvatar(
+ child: Icon(Icons.timer),
),
- )),
+ title: Text(
+ projectName,
+ style: projectName == 'Unknown project'
+ ? theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ )
+ : null,
+ ),
+ subtitle: Text(entry.notes ?? ''),
+ trailing: Text(entry.formattedDuration),
+ ),
+ );
+ }),
],
),
),
diff --git a/mobile/lib/presentation/screens/login_screen.dart b/mobile/lib/presentation/screens/login_screen.dart
index 0c5787e9..7d3eeb2f 100644
--- a/mobile/lib/presentation/screens/login_screen.dart
+++ b/mobile/lib/presentation/screens/login_screen.dart
@@ -1,9 +1,12 @@
+import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:timetracker_mobile/core/config/app_config.dart';
+import 'package:timetracker_mobile/core/constants/app_constants.dart';
import 'package:timetracker_mobile/data/api/api_client.dart';
-import 'package:timetracker_mobile/presentation/screens/timer_screen.dart';
+import 'package:timetracker_mobile/utils/ssl/certificate_error.dart';
+import 'package:timetracker_mobile/utils/ssl/ssl_utils.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -15,7 +18,8 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState {
final _formKey = GlobalKey();
final _serverUrlController = TextEditingController();
- final _apiTokenController = TextEditingController();
+ final _usernameController = TextEditingController();
+ final _passwordController = TextEditingController();
final _storage = const FlutterSecureStorage();
bool _isLoading = false;
String? _error;
@@ -36,10 +40,30 @@ class _LoginScreenState extends ConsumerState {
@override
void dispose() {
_serverUrlController.dispose();
- _apiTokenController.dispose();
+ _usernameController.dispose();
+ _passwordController.dispose();
super.dispose();
}
+ /// True if host is likely local/emulator (self-signed cert is common).
+ static bool _isLikelyLocalOrEmulator(String host) {
+ final h = host.toLowerCase();
+ if (h == '10.0.2.2' || h == 'localhost' || h == '127.0.0.1') return true;
+ if (h.startsWith('192.168.') || h.startsWith('10.') || h.startsWith('172.')) return true;
+ return false;
+ }
+
+ /// Normalize server URL: add https:// if no scheme, then ensure valid base.
+ static String _normalizeServerUrl(String input) {
+ final trimmed = input.trim();
+ if (trimmed.isEmpty) return trimmed;
+ if (trimmed.toLowerCase().startsWith('https://') ||
+ trimmed.toLowerCase().startsWith('http://')) {
+ return trimmed;
+ }
+ return 'https://$trimmed';
+ }
+
Future _handleLogin() async {
if (!_formKey.currentState!.validate()) {
return;
@@ -51,47 +75,132 @@ class _LoginScreenState extends ConsumerState {
});
try {
- final serverUrl = _serverUrlController.text.trim();
- final apiToken = _apiTokenController.text.trim();
+ final rawUrl = _serverUrlController.text.trim();
+ final serverUrl = _normalizeServerUrl(rawUrl);
+ final username = _usernameController.text.trim();
+ final password = _passwordController.text;
- // Validate token format
- if (!apiToken.startsWith('tt_')) {
+ final baseUrl = serverUrl.endsWith('/') ? serverUrl : '$serverUrl/';
+ final trustedHosts = await AppConfig.getTrustedInsecureHosts();
+ final dio = Dio(BaseOptions(
+ baseUrl: baseUrl,
+ connectTimeout: const Duration(seconds: 15),
+ receiveTimeout: const Duration(seconds: 15),
+ headers: {'Content-Type': 'application/json'},
+ ));
+ configureDioTrustedHosts(dio, trustedHosts);
+
+ final response = await dio.post