Mobile: theme, login, sync, and launcher icons
- Align app theme with webapp (AppColors, light/dark ColorScheme); move to core/theme - Add app config, theme mode provider, empty_state and error_view widgets - Improve API client, sync service, and repository for time entries - Add login screen, settings (theme toggle, logout), SSL utils for dev certs - Android/iOS project config, Gradle wrapper, and generated launcher icons - Add flutter_launcher_icons and icon assets (app_icon.png, README) Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
package="com.timetracker.mobile">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:label="TimeTracker"
|
||||
android:name="${applicationName}">
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.connectivity.ConnectivityPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin connectivity_plus, dev.fluttercommunity.plus.connectivity.ConnectivityPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.baseflow.permissionhandler.PermissionHandlerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin permission_handler_android, com.baseflow.permissionhandler.PermissionHandlerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -1,16 +1,3 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.0'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.3.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
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 "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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.)
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 --")
|
||||
@@ -0,0 +1,5 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
command script import --relative-to-command-file flutter_lldb_helper.py
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GeneratedPluginRegistrant_h
|
||||
#define GeneratedPluginRegistrant_h
|
||||
|
||||
#import <Flutter/Flutter.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface GeneratedPluginRegistrant : NSObject
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
#endif /* GeneratedPluginRegistrant_h */
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<connectivity_plus/ConnectivityPlusPlugin.h>)
|
||||
#import <connectivity_plus/ConnectivityPlusPlugin.h>
|
||||
#else
|
||||
@import connectivity_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
|
||||
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
|
||||
#else
|
||||
@import flutter_local_notifications;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_secure_storage/FlutterSecureStoragePlugin.h>)
|
||||
#import <flutter_secure_storage/FlutterSecureStoragePlugin.h>
|
||||
#else
|
||||
@import flutter_secure_storage;
|
||||
#endif
|
||||
|
||||
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
|
||||
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
|
||||
#else
|
||||
@import package_info_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
|
||||
#import <permission_handler_apple/PermissionHandlerPlugin.h>
|
||||
#else
|
||||
@import permission_handler_apple;
|
||||
#endif
|
||||
|
||||
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
|
||||
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
||||
#else
|
||||
@import shared_preferences_foundation;
|
||||
#endif
|
||||
|
||||
#if __has_include(<workmanager_apple/WorkmanagerPlugin.h>)
|
||||
#import <workmanager_apple/WorkmanagerPlugin.h>
|
||||
#else
|
||||
@import workmanager_apple;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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
|
||||
@@ -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<bool> getAutoSync() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(autoSyncKey) ?? true;
|
||||
}
|
||||
|
||||
/// Set auto sync setting
|
||||
static Future<void> setAutoSync(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(autoSyncKey, value);
|
||||
}
|
||||
|
||||
/// Get sync interval (seconds)
|
||||
static Future<int> getSyncInterval() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(syncIntervalKey) ?? 60;
|
||||
}
|
||||
|
||||
/// Get theme mode
|
||||
static Future<String> getThemeMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(themeModeKey) ?? 'system';
|
||||
}
|
||||
|
||||
/// Set theme mode ('system', 'light', 'dark')
|
||||
static Future<void> 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<Set<String>> getTrustedInsecureHosts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(trustedInsecureHostsKey);
|
||||
return list != null ? list.toSet() : <String>{};
|
||||
}
|
||||
|
||||
/// Add a host to the trusted insecure hosts set (user accepted the cert)
|
||||
static Future<void> 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<void> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<String> 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<void> setAuthToken(String token) async {
|
||||
_authToken = token;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> _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<bool> _syncData() async {
|
||||
|
||||
class WorkManagerService {
|
||||
static Future<void> initialize() async {
|
||||
await Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
|
||||
await Workmanager().initialize(callbackDispatcher);
|
||||
}
|
||||
|
||||
static Future<void> startTimerStatusUpdates() async {
|
||||
|
||||
@@ -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<void> _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<void> 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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.from(value));
|
||||
} else if (value is String) {
|
||||
// If stored as string, parse it (though Hive usually stores as Map)
|
||||
return Project.fromJson(Map<String, dynamic>.from(value));
|
||||
return Project.fromJson(
|
||||
Map<String, dynamic>.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<String, dynamic>.from(value));
|
||||
} else if (value is String) {
|
||||
return TimeEntry.fromJson(Map<String, dynamic>.from(value));
|
||||
return TimeEntry.fromJson(
|
||||
Map<String, dynamic>.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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,16 @@ class Project {
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'client': client,
|
||||
'status': status,
|
||||
'billable': billable,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>);
|
||||
final entry = TimeEntry.fromJson(response['time_entry'] as Map<String, dynamic>);
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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<void> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<ApiClient?>((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)
|
||||
|
||||
@@ -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<String> {
|
||||
ThemeModeNotifier() : super('system') {
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
AppConfig.getThemeMode().then((value) {
|
||||
state = value;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setMode(String value) async {
|
||||
await AppConfig.setThemeMode(value);
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final themeModeProvider =
|
||||
StateNotifierProvider<ThemeModeNotifier, String>((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;
|
||||
}
|
||||
}
|
||||
@@ -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<TimerState> {
|
||||
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<TimerState> {
|
||||
}
|
||||
|
||||
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<TimerState> {
|
||||
}
|
||||
|
||||
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<TimerState> {
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -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<DashboardTab> {
|
||||
// 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<DashboardTab> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _projectName(int? projectId, List<Project> 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<int>(
|
||||
@@ -173,7 +188,7 @@ class _DashboardTabState extends ConsumerState<DashboardTab> {
|
||||
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<DashboardTab> {
|
||||
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<DashboardTab> {
|
||||
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<DashboardTab> {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<LoginScreen> {
|
||||
@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<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
@@ -51,47 +75,132 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
});
|
||||
|
||||
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<Map<String, dynamic>>(
|
||||
'/api/v1/auth/login',
|
||||
data: {'username': username, 'password': password},
|
||||
);
|
||||
|
||||
final token = response.data?['token'] as String?;
|
||||
if (token == null || token.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'API token must start with "tt_"';
|
||||
_error = 'Invalid username or password';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
await AppConfig.setServerUrl(serverUrl);
|
||||
await _storage.write(key: 'api_token', value: apiToken);
|
||||
await _storage.write(key: 'api_token', value: token);
|
||||
|
||||
// Validate connection
|
||||
final apiClient = ApiClient(baseUrl: serverUrl);
|
||||
await apiClient.setAuthToken(apiToken);
|
||||
final apiClient = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts);
|
||||
await apiClient.setAuthToken(token);
|
||||
final isValid = await apiClient.validateToken();
|
||||
|
||||
if (!isValid) {
|
||||
setState(() {
|
||||
_error = 'Invalid API token. Please check your token.';
|
||||
_error = 'Invalid username or password';
|
||||
_isLoading = false;
|
||||
});
|
||||
await _storage.delete(key: 'api_token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to main app
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const TimerScreen()),
|
||||
Navigator.of(context).pushReplacementNamed(AppConstants.routeHome);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
final host = e.requestOptions.uri.host;
|
||||
final isConnectionFailure = e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.unknown;
|
||||
final isCertError = isCertificateError(e);
|
||||
final showTrustOption = host.isNotEmpty &&
|
||||
(isCertError || (isConnectionFailure && _isLikelyLocalOrEmulator(host)));
|
||||
|
||||
if (showTrustOption && mounted) {
|
||||
final trust = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isCertError ? 'Certificate not trusted' : 'Connection failed'),
|
||||
content: Text(
|
||||
isCertError
|
||||
? 'The server\'s certificate could not be verified for "$host". '
|
||||
'This often happens with self-signed certificates (e.g. local or emulator). '
|
||||
'Trust it and try again?'
|
||||
: 'Cannot reach "$host". '
|
||||
'If the server uses a self-signed certificate (common at 10.0.2.2 or local IPs), '
|
||||
'tap "Trust and retry". Otherwise check the URL and port (e.g. https://10.0.2.2:443 or :8443).',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(isCertError ? 'Yes, trust' : 'Trust and retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (trust == true && mounted) {
|
||||
await AppConfig.addTrustedInsecureHost(host);
|
||||
await _handleLogin();
|
||||
return;
|
||||
}
|
||||
} else if (isCertError && mounted && host.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Certificate error. Check the server URL and try again.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final statusCode = e.response?.statusCode;
|
||||
final message = e.response?.data is Map
|
||||
? (e.response!.data as Map)['error'] as String?
|
||||
: null;
|
||||
String errMsg;
|
||||
if (statusCode == 401) {
|
||||
errMsg = message ?? 'Invalid username or password';
|
||||
} else if (statusCode != null && statusCode >= 400) {
|
||||
errMsg = message ?? 'Server returned error ($statusCode). Check the URL.';
|
||||
} else if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
errMsg = 'Connection timed out. Check your network and that the server is reachable.';
|
||||
} else if (e.type == DioExceptionType.connectionError) {
|
||||
errMsg = 'Cannot reach server. Check the URL, your network, and that the server is running.';
|
||||
} else {
|
||||
errMsg = message ?? 'Connection failed. Check the URL and try again.';
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = errMsg;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Connection failed: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Connection failed. Check the URL and your network, then try again.';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +217,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.timer,
|
||||
size: 80,
|
||||
color: Colors.blue,
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.timer,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
@@ -130,37 +254,62 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
controller: _serverUrlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://your-server.com',
|
||||
hintText: 'your-server.com or https://your-server.com',
|
||||
prefixIcon: Icon(Icons.link),
|
||||
helperText: 'Self-signed certificates may require Trust.',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter server URL';
|
||||
}
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || !uri.hasAbsolutePath) {
|
||||
return 'Please enter a valid URL';
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.contains('://')) {
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null ||
|
||||
!uri.hasScheme ||
|
||||
uri.host.isEmpty ||
|
||||
(!uri.scheme.toLowerCase().startsWith('http'))) {
|
||||
return 'Enter a valid URL (e.g. https://your-server.com)';
|
||||
}
|
||||
} else {
|
||||
final withScheme = Uri.tryParse('https://$trimmed');
|
||||
if (withScheme == null || withScheme.host.isEmpty) {
|
||||
return 'Enter a valid server address (e.g. your-server.com or https://...)';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _apiTokenController,
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Token',
|
||||
hintText: 'tt_...',
|
||||
prefixIcon: Icon(Icons.key),
|
||||
helperText: 'Get your API token from Admin > API Tokens',
|
||||
labelText: 'Username',
|
||||
hintText: 'Your web login username',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
textCapitalization: TextCapitalization.none,
|
||||
autocorrect: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter username';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Your web login password',
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter API token';
|
||||
}
|
||||
if (!value.startsWith('tt_')) {
|
||||
return 'Token must start with "tt_"';
|
||||
return 'Please enter password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -170,13 +319,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Colors.red.shade700),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../providers/projects_provider.dart';
|
||||
import '../providers/timer_provider.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/error_view.dart';
|
||||
import 'timer_screen.dart';
|
||||
|
||||
class ProjectsScreen extends ConsumerStatefulWidget {
|
||||
@@ -63,21 +65,17 @@ class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
|
||||
child: projectsState.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: projectsState.error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Error: ${projectsState.error}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.read(projectsProvider.notifier).loadProjects(),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
? ErrorView(
|
||||
title: 'Error loading projects',
|
||||
message: projectsState.error,
|
||||
onRetry: () => ref.read(projectsProvider.notifier).loadProjects(),
|
||||
)
|
||||
: filteredProjects.isEmpty
|
||||
? const Center(child: Text('No projects found'))
|
||||
? const EmptyState(
|
||||
icon: Icons.folder_off,
|
||||
title: 'No projects found',
|
||||
subtitle: 'No projects match your search.',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filteredProjects.length,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../utils/auth/auth_service.dart';
|
||||
import '../screens/login_screen.dart';
|
||||
import '../providers/theme_mode_provider.dart';
|
||||
import 'login_screen.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -13,32 +14,104 @@ class SettingsScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
bool _isLoading = true;
|
||||
String? _serverUrl;
|
||||
int _syncInterval = 60;
|
||||
bool _autoSync = true;
|
||||
String _version = '—';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConfig();
|
||||
}
|
||||
|
||||
Future<void> _loadConfig() async {
|
||||
final serverUrl = await AppConfig.getServerUrl();
|
||||
final syncInterval = await AppConfig.getSyncInterval();
|
||||
final autoSync = await AppConfig.getAutoSync();
|
||||
String version = '—';
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
version = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverUrl = serverUrl;
|
||||
_syncInterval = syncInterval;
|
||||
_autoSync = autoSync;
|
||||
_version = version;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showThemePicker() {
|
||||
final themeMode = ref.read(themeModeProvider);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Theme'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ThemeOption(
|
||||
label: 'System',
|
||||
value: 'system',
|
||||
current: themeMode,
|
||||
onTap: () => _selectTheme('system'),
|
||||
),
|
||||
_ThemeOption(
|
||||
label: 'Light',
|
||||
value: 'light',
|
||||
current: themeMode,
|
||||
onTap: () => _selectTheme('light'),
|
||||
),
|
||||
_ThemeOption(
|
||||
label: 'Dark',
|
||||
value: 'dark',
|
||||
current: themeMode,
|
||||
onTap: () => _selectTheme('dark'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectTheme(String value) {
|
||||
ref.read(themeModeProvider.notifier).setMode(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final serverUrl = AppConfig.serverUrl ?? 'Not configured';
|
||||
final syncInterval = AppConfig.syncInterval;
|
||||
final autoSync = AppConfig.autoSync;
|
||||
final themeMode = AppConfig.themeMode;
|
||||
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
// Server Configuration
|
||||
_sectionHeader('Account'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.dns),
|
||||
title: const Text('Server URL'),
|
||||
subtitle: Text(serverUrl),
|
||||
subtitle: Text(_serverUrl?.isNotEmpty == true ? _serverUrl! : 'Not configured'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// TODO: Edit server URL
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// API Token
|
||||
ListTile(
|
||||
leading: const Icon(Icons.key),
|
||||
title: const Text('API Token'),
|
||||
@@ -48,58 +121,48 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
// TODO: Edit API token
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Sync Settings
|
||||
_sectionHeader('Sync'),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.sync),
|
||||
title: const Text('Auto Sync'),
|
||||
subtitle: const Text('Automatically sync data when online'),
|
||||
value: autoSync,
|
||||
value: _autoSync,
|
||||
onChanged: (value) async {
|
||||
await AppConfig.setAutoSync(value);
|
||||
setState(() {});
|
||||
if (mounted) setState(() => _autoSync = value);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: const Text('Sync Interval'),
|
||||
subtitle: Text('$syncInterval seconds'),
|
||||
subtitle: Text('$_syncInterval seconds'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// TODO: Edit sync interval
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Theme
|
||||
_sectionHeader('Appearance'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
title: const Text('Theme'),
|
||||
subtitle: Text(themeMode == 'system' ? 'System' : themeMode == 'dark' ? 'Dark' : 'Light'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// TODO: Select theme
|
||||
},
|
||||
onTap: _showThemePicker,
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// About
|
||||
_sectionHeader('About'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('Version 1.0.0'),
|
||||
subtitle: Text('Version $_version'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// TODO: Show about dialog
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Logout
|
||||
_sectionHeader('Account'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout, color: Colors.red),
|
||||
title: const Text('Logout', style: TextStyle(color: Colors.red)),
|
||||
leading: Icon(Icons.logout, color: colorScheme.error),
|
||||
title: Text('Logout', style: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w500)),
|
||||
onTap: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -113,12 +176,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Logout', style: TextStyle(color: Colors.red)),
|
||||
child: Text('Logout', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
await AuthService.deleteToken();
|
||||
await AppConfig.clear();
|
||||
@@ -135,4 +197,45 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String title) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeOption extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String current;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ThemeOption({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.current,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSelected = current == value;
|
||||
return ListTile(
|
||||
title: Text(label),
|
||||
trailing: isSelected ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
onTap();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import 'login_screen.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
@@ -20,34 +18,55 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
_checkAuthStatus();
|
||||
}
|
||||
|
||||
Future<void> _checkAuthStatus() async {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
void _checkAuthStatus() {
|
||||
_continueAuthCheck();
|
||||
}
|
||||
|
||||
Future<void> _continueAuthCheck() async {
|
||||
final start = DateTime.now();
|
||||
final serverUrl = await AppConfig.getServerUrl();
|
||||
const storage = FlutterSecureStorage();
|
||||
final token = await storage.read(key: AppConfig.apiTokenKey);
|
||||
final hasToken = token != null && token.isNotEmpty;
|
||||
|
||||
if (serverUrl != null && serverUrl.isNotEmpty && hasToken) {
|
||||
Navigator.of(context).pushReplacementNamed(AppConstants.routeHome);
|
||||
} else {
|
||||
Navigator.of(context).pushReplacementNamed(AppConstants.routeLogin);
|
||||
final route = (serverUrl != null && serverUrl.isNotEmpty && hasToken)
|
||||
? AppConstants.routeHome
|
||||
: AppConstants.routeLogin;
|
||||
final elapsed = DateTime.now().difference(start).inMilliseconds;
|
||||
const minDisplayMs = 800;
|
||||
if (elapsed < minDisplayMs) {
|
||||
await Future.delayed(Duration(milliseconds: minDisplayMs - elapsed));
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed(route);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.timer,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
@@ -60,6 +79,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/time_entries_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/screens/time_entry_form_screen.dart';
|
||||
import 'package:timetracker_mobile/presentation/widgets/empty_state.dart';
|
||||
import 'package:timetracker_mobile/presentation/widgets/error_view.dart';
|
||||
import 'package:timetracker_mobile/presentation/widgets/time_entry_card.dart';
|
||||
|
||||
class TimeEntriesScreen extends ConsumerStatefulWidget {
|
||||
@@ -58,58 +60,18 @@ class _TimeEntriesScreenState extends ConsumerState<TimeEntriesScreen> {
|
||||
}
|
||||
|
||||
if (state.error != null && state.entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red.shade300,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Error loading entries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.error!,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.read(timeEntriesProvider.notifier).refresh(),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
return ErrorView(
|
||||
title: 'Error loading entries',
|
||||
message: state.error,
|
||||
onRetry: () => ref.read(timeEntriesProvider.notifier).refresh(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 64,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No time entries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Start tracking time or add a manual entry',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
return const EmptyState(
|
||||
icon: Icons.history,
|
||||
title: 'No time entries',
|
||||
subtitle: 'Start tracking time or add a manual entry',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,8 +122,8 @@ class _TimeEntriesScreenState extends ConsumerState<TimeEntriesScreen> {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:timetracker_mobile/data/models/time_entry.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/projects_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/tasks_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/time_entries_provider.dart';
|
||||
@@ -196,7 +195,10 @@ class _TimeEntryFormScreenState extends ConsumerState<TimeEntryFormScreen> {
|
||||
labelText: 'Project *',
|
||||
prefixIcon: Icon(Icons.folder),
|
||||
),
|
||||
value: _selectedProjectId,
|
||||
initialValue: _selectedProjectId != null &&
|
||||
projectsState.projects.any((p) => p.id == _selectedProjectId)
|
||||
? _selectedProjectId
|
||||
: null,
|
||||
items: projectsState.projects
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
@@ -227,7 +229,10 @@ class _TimeEntryFormScreenState extends ConsumerState<TimeEntryFormScreen> {
|
||||
labelText: 'Task (Optional)',
|
||||
prefixIcon: Icon(Icons.task),
|
||||
),
|
||||
value: _selectedTaskId,
|
||||
initialValue: _selectedTaskId != null &&
|
||||
tasksState.tasks.any((t) => t.id == _selectedTaskId)
|
||||
? _selectedTaskId
|
||||
: null,
|
||||
items: [
|
||||
const DropdownMenuItem<int>(
|
||||
value: null,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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/presentation/providers/projects_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/screens/login_screen.dart';
|
||||
import 'package:timetracker_mobile/utils/auth/auth_service.dart';
|
||||
import 'package:timetracker_mobile/presentation/screens/time_entries_screen.dart';
|
||||
import 'package:timetracker_mobile/presentation/widgets/timer_widget.dart';
|
||||
|
||||
@@ -18,6 +19,7 @@ class _TimerScreenState extends ConsumerState<TimerScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('TimeTracker'),
|
||||
@@ -26,6 +28,7 @@ class _TimerScreenState extends ConsumerState<TimerScreen> {
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: _handleLogout,
|
||||
tooltip: 'Logout',
|
||||
style: IconButton.styleFrom(foregroundColor: colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -60,6 +63,7 @@ class _TimerScreenState extends ConsumerState<TimerScreen> {
|
||||
}
|
||||
|
||||
Future<void> _handleLogout() async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -70,33 +74,46 @@ class _TimerScreenState extends ConsumerState<TimerScreen> {
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Logout'),
|
||||
child: Text('Logout', style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.delete(key: 'api_token');
|
||||
await AppConfig.clearServerUrl();
|
||||
await AuthService.deleteToken();
|
||||
await AppConfig.clear();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TimerTab extends ConsumerWidget {
|
||||
class _TimerTab extends ConsumerStatefulWidget {
|
||||
const _TimerTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<_TimerTab> createState() => _TimerTabState();
|
||||
}
|
||||
|
||||
class _TimerTabState extends ConsumerState<_TimerTab> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(projectsProvider.notifier).loadProjects();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Reusable empty state: icon + title + optional subtitle + optional action.
|
||||
class EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? action;
|
||||
|
||||
const EmptyState({
|
||||
super.key,
|
||||
this.icon = Icons.inbox_outlined,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Reusable error state: icon + title + message + optional retry action.
|
||||
class ErrorView extends StatelessWidget {
|
||||
final String title;
|
||||
final String? message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorView({
|
||||
super.key,
|
||||
this.title = 'Something went wrong',
|
||||
this.message,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (message != null && message!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,13 +101,13 @@ class TimeEntryCard extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: Colors.grey.shade600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
entry.formattedDateRange,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (entry.billable) ...[
|
||||
@@ -118,14 +118,14 @@ class TimeEntryCard extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Billable',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.green.shade700,
|
||||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -159,7 +159,7 @@ class TimeEntryCard extends ConsumerWidget {
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
label: const Text('Delete'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:timetracker_mobile/data/models/project.dart';
|
||||
import 'package:timetracker_mobile/data/models/task.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/api_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/timer_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/projects_provider.dart';
|
||||
import 'package:timetracker_mobile/presentation/providers/tasks_provider.dart';
|
||||
@@ -112,8 +113,8 @@ class _TimerWidgetState extends ConsumerState<TimerWidget> {
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('Stop Timer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -124,7 +125,7 @@ class _TimerWidgetState extends ConsumerState<TimerWidget> {
|
||||
Text(
|
||||
'00:00:00',
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
color: Colors.grey,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -147,12 +148,12 @@ class _TimerWidgetState extends ConsumerState<TimerWidget> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
timerState.error!,
|
||||
style: TextStyle(color: Colors.red.shade700),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -217,15 +218,29 @@ class _StartTimerDialogState extends ConsumerState<StartTimerDialog> {
|
||||
: _notesController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
if (!mounted) return;
|
||||
final timerState = ref.read(timerProvider);
|
||||
if (timerState.error != null) {
|
||||
// Keep dialog open and show error; do not pop
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final apiClientAsync = ref.watch(apiClientProvider);
|
||||
final projectsState = ref.watch(projectsProvider);
|
||||
final tasksState = ref.watch(tasksProvider);
|
||||
final timerState = ref.watch(timerProvider);
|
||||
|
||||
final isApiReady = apiClientAsync.when(
|
||||
data: (client) => client != null,
|
||||
loading: () => false,
|
||||
error: (_, __) => false,
|
||||
);
|
||||
final isApiLoading = apiClientAsync.isLoading;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Start Timer'),
|
||||
@@ -234,13 +249,39 @@ class _StartTimerDialogState extends ConsumerState<StartTimerDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isApiLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (!isApiReady) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Not connected to server. Check settings and try again.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
] else ...[
|
||||
// Project selection
|
||||
DropdownButtonFormField<int>(
|
||||
key: ValueKey('project_$_selectedProjectId'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Project',
|
||||
prefixIcon: Icon(Icons.folder),
|
||||
),
|
||||
value: _selectedProjectId,
|
||||
initialValue: _selectedProjectId != null &&
|
||||
projectsState.projects.any((p) => p.id == _selectedProjectId)
|
||||
? _selectedProjectId
|
||||
: null,
|
||||
items: projectsState.projects
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
@@ -261,11 +302,15 @@ class _StartTimerDialogState extends ConsumerState<StartTimerDialog> {
|
||||
// Task selection (optional)
|
||||
if (_selectedProjectId != null)
|
||||
DropdownButtonFormField<int>(
|
||||
key: ValueKey('task_$_selectedTaskId'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Task (Optional)',
|
||||
prefixIcon: Icon(Icons.task),
|
||||
),
|
||||
value: _selectedTaskId,
|
||||
initialValue: _selectedTaskId != null &&
|
||||
tasksState.tasks.any((t) => t.id == _selectedTaskId)
|
||||
? _selectedTaskId
|
||||
: null,
|
||||
items: [
|
||||
const DropdownMenuItem<int>(
|
||||
value: null,
|
||||
@@ -294,17 +339,44 @@ class _StartTimerDialogState extends ConsumerState<StartTimerDialog> {
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
if (timerState.error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
timerState.error!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: timerState.isLoading || isApiLoading
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _handleStart,
|
||||
child: const Text('Start'),
|
||||
onPressed: (timerState.isLoading || isApiLoading || !isApiReady)
|
||||
? null
|
||||
: _handleStart,
|
||||
child: timerState.isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Start'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'certificate_error_io.dart' if (dart.library.io) 'certificate_error_stub.dart';
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Detects SSL/certificate errors (native platforms with dart:io).
|
||||
bool isCertificateError(DioException e) {
|
||||
if (e.type == DioExceptionType.connectionError && e.error != null) {
|
||||
final err = e.error!;
|
||||
if (err is HandshakeException || err is TlsException) return true;
|
||||
final errStr = err.toString().toLowerCase();
|
||||
if (errStr.contains('certificate') ||
|
||||
errStr.contains('handshake') ||
|
||||
errStr.contains('certificate_verify_failed') ||
|
||||
errStr.contains('ssl')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
final msg = e.message?.toLowerCase() ?? '';
|
||||
if (msg.contains('certificate') ||
|
||||
msg.contains('handshake') ||
|
||||
msg.contains('certificate_verify_failed') ||
|
||||
msg.contains('ssl')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Stub: detect certificate errors by message only (e.g. on web where dart:io is unavailable).
|
||||
bool isCertificateError(DioException e) {
|
||||
final msg = e.message?.toLowerCase() ?? '';
|
||||
final errStr = e.error?.toString().toLowerCase() ?? '';
|
||||
return msg.contains('certificate') ||
|
||||
msg.contains('handshake') ||
|
||||
msg.contains('certificate_verify_failed') ||
|
||||
msg.contains('ssl') ||
|
||||
errStr.contains('certificate') ||
|
||||
errStr.contains('handshake') ||
|
||||
errStr.contains('ssl');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
|
||||
/// Configures [dio] to trust HTTPS certificates for [trustedHosts] (e.g. self-signed).
|
||||
/// Only has effect on platforms that use [IOHttpClientAdapter] (Android, iOS, macOS, etc.).
|
||||
void configureDioTrustedHosts(Dio dio, Set<String> trustedHosts) {
|
||||
if (trustedHosts.isEmpty) return;
|
||||
final adapter = dio.httpClientAdapter;
|
||||
if (adapter is IOHttpClientAdapter) {
|
||||
adapter.createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
client.badCertificateCallback = (_, host, __) => trustedHosts.contains(host);
|
||||
return client;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
name: timetracker_mobile
|
||||
description: TimeTracker mobile app for Android and iOS
|
||||
publish_to: 'none'
|
||||
version: 4.15.0+1
|
||||
version: 4.15.1+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -25,10 +25,10 @@ dependencies:
|
||||
flutter_secure_storage: ^9.0.0
|
||||
|
||||
# Background Tasks
|
||||
workmanager: ^0.5.2
|
||||
workmanager: ^0.9.0+3
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^16.3.0
|
||||
flutter_local_notifications: ^17.2.1
|
||||
permission_handler: ^11.1.0
|
||||
|
||||
# UI Components
|
||||
@@ -39,6 +39,7 @@ dependencies:
|
||||
shared_preferences: ^2.2.2
|
||||
connectivity_plus: ^5.0.2
|
||||
timeago: ^3.6.1
|
||||
package_info_plus: ^8.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -46,11 +47,17 @@ dev_dependencies:
|
||||
flutter_lints: ^3.0.0
|
||||
hive_generator: ^2.0.1
|
||||
build_runner: ^2.4.7
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# Assets commented out until images/icons directories are created
|
||||
# assets:
|
||||
# - assets/images/
|
||||
# - assets/icons/
|
||||
|
||||
assets:
|
||||
- assets/icon/
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: false
|
||||
image_path: "assets/icon/app_icon.png"
|
||||
adaptive_icon_background: "#4A90E2"
|
||||
adaptive_icon_foreground: "assets/icon/app_icon.png"
|
||||
|
||||
@@ -6,7 +6,7 @@ void main() {
|
||||
test('initializes with base URL', () {
|
||||
const baseUrl = 'https://example.com';
|
||||
final client = ApiClient(baseUrl: baseUrl);
|
||||
expect(client.baseUrl, baseUrl);
|
||||
expect(client.baseUrl, 'https://example.com/');
|
||||
});
|
||||
|
||||
test('validates token format', () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:timetracker_mobile/main.dart';
|
||||
|
||||
void main() {
|
||||
|
||||