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>
This commit is contained in:
Dries Peeters
2026-02-01 16:51:05 +01:00
parent 0e7656134e
commit 9066089a34
86 changed files with 2899 additions and 447 deletions
+17 -32
View File
@@ -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 servers HTTPS URL (e.g. `https://your-server.com`) with no port unless you use a custom one.
- **Production:** Use a valid certificate (e.g. Lets 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
+15 -16
View File
@@ -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);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

-13
View File
@@ -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()
Binary file not shown.
@@ -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
+160
View File
@@ -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 "$@"
+90
View File
@@ -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
+5 -2
View File
@@ -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
+21 -10
View File
@@ -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"
+23
View File
@@ -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.)
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+5
View File
@@ -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"
+14
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

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
+41
View File
@@ -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);
}
+153 -10
View File
@@ -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,
),
);
}
}
-76
View File
@@ -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),
),
),
),
);
}
+8 -4
View File
@@ -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 [];
}
}
}
+12
View File
@@ -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');
}
+9 -15
View File
@@ -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;
}
}
+9 -6
View File
@@ -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),
),
);
}),
],
),
),
+190 -41
View File
@@ -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');
}
+18
View File
@@ -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;
};
}
}
+1106
View File
File diff suppressed because it is too large Load Diff
+15 -8
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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() {