diff --git a/mobile/README.md b/mobile/README.md index 7576278c..b9912019 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -8,7 +8,7 @@ Flutter mobile application for Android and iOS that integrates with the TimeTrac - πŸ“Š **Projects & Tasks** - View and select projects and tasks - πŸ“ **Time Entries** - View and manage time entries with calendar - πŸ”„ **Offline Support** - Work offline with automatic sync -- πŸ” **Secure Authentication** - Token-based authentication with secure storage +- πŸ” **Secure Authentication** - Sign in with your web username and password; the app obtains an API token in the background for the same basics access as the web app ## Setup @@ -36,45 +36,30 @@ flutter run ## Configuration -### Getting an API Token +### Signing in -Before connecting the mobile app, you need to create an API token: - -1. **Log in to TimeTracker Web App** as an administrator -2. Navigate to **Admin > API Tokens** (`/admin/api-tokens`) -3. Click **"Create Token"** -4. Fill in the required information: - - **Name**: A descriptive name (e.g., "Mobile App - John") - - **User**: Select the user this token will authenticate as - - **Scopes**: Select the following permissions: - - `read:projects` - View projects - - `read:tasks` - View tasks - - `read:time_entries` - View time entries - - `write:time_entries` - Create and update time entries - - **Expires In**: Optional expiration period (leave empty for no expiration) -5. Click **"Create Token"** -6. **Important**: Copy the generated token immediately - you won't be able to see it again! - - Token format: `tt_<32_random_characters>` - - Example: `tt_abc123def456ghi789jkl012mno345pq` - -### Connecting the App +Use the same **username and password** you use to log in to the TimeTracker web app. The mobile app signs you in via the API and obtains an API token in the background, giving you the same basics access (timer, time entries, projects, tasks) as on the web. 1. **Launch the app** on your device 2. On the login screen, enter: - - **Server URL**: Your TimeTracker server URL (e.g., `https://your-server.com`) - - Do not include a trailing slash - - Use `http://` for local development or `https://` for production - - **API Token**: Paste the token you copied from the web app + - **Server URL**: The base URL of your TimeTracker server (e.g., `https://your-server.com`). Use HTTPS if your server uses SSL. + - **Username**: Your web login username + - **Password**: Your web login password 3. Tap **"Login"** -4. The app will validate your connection and navigate to the timer screen if successful +4. The app will validate your credentials and navigate to the home screen if successful + +### Server URL and HTTPS + +The default TimeTracker deployment uses **docker-compose** with **NGINX** on ports 80 and 443 (HTTPS). Use your server’s HTTPS URL (e.g. `https://your-server.com`) with no port unless you use a custom one. + +- **Production:** Use a valid certificate (e.g. Let’s Encrypt) so the app can connect without certificate errors. +- **Local / testing:** Use a trusted CA (e.g. [mkcert](https://github.com/FiloSottile/mkcert)) for HTTPS, or HTTP only if your setup serves the API over HTTP (e.g. dev without NGINX). ### Troubleshooting -**"Invalid API token" error:** -- Verify the token starts with `tt_` -- Check that the token hasn't expired -- Ensure the token has the required scopes -- Try creating a new token in the web app +**"Invalid username or password" error:** +- Use the same username and password you use on the web app +- Ensure the server URL is correct and the server is reachable **"Connection failed" error:** - Verify the server URL is correct and accessible diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 14d903f1..9bab837c 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,22 +22,19 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { namespace "com.timetracker.mobile" - compileSdkVersion 34 + compileSdk 36 ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } sourceSets { @@ -45,8 +43,8 @@ android { defaultConfig { applicationId "com.timetracker.mobile" - minSdkVersion 21 - targetSdkVersion 34 + minSdkVersion flutter.minSdkVersion + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -63,5 +61,6 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' } + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index e988f0f4..41fb56fc 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,10 +2,14 @@ package="com.timetracker.mobile"> + + android:name="${applicationName}" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/mobile/android/gradlew.bat b/mobile/android/gradlew.bat new file mode 100644 index 00000000..8a0b282a --- /dev/null +++ b/mobile/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/android/local.properties b/mobile/android/local.properties index 958ca3c1..929c6902 100644 --- a/mobile/android/local.properties +++ b/mobile/android/local.properties @@ -1,2 +1,5 @@ -flutter.versionCode=41500 -flutter.versionName=4.15.0 +flutter.versionCode=1 +flutter.versionName=4.15.1 +flutter.sdk=C:\\Flutter\\flutter +sdk.dir=C:\\Users\\dries\\AppData\\Local\\Android\\Sdk +flutter.buildMode=release \ No newline at end of file diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 44e62bcf..7ede3750 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -1,11 +1,22 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} +include ":app" diff --git a/mobile/assets/icon/README.md b/mobile/assets/icon/README.md new file mode 100644 index 00000000..639075c1 --- /dev/null +++ b/mobile/assets/icon/README.md @@ -0,0 +1,23 @@ +# App icon source + +The launcher icon is generated at build time from `app_icon.png` (1024Γ—1024) by `flutter_launcher_icons`. + +## Creating or updating `app_icon.png` + +Export the TimeTracker icon from the web app assets: + +- **Source:** `app/static/images/timetracker-logo-icon.svg` (project root) +- **Size:** 1024Γ—1024 pixels, PNG + +You can export once using: + +- **ImageMagick:** `magick ../../app/static/images/timetracker-logo-icon.svg -resize 1024x1024 app_icon.png` +- **Inkscape:** Export as PNG at 1024Γ—1024 from the SVG. +- **Browser:** Open the SVG, use dev tools or a screenshot tool at 1024Γ—1024. + +Alternatively, run the project script from the repo root: + +- Windows: `scripts\generate-mobile-icon.bat` +- Linux/macOS: `./scripts/generate-mobile-icon.sh` + +(Requires ImageMagick or Inkscape to be installed.) diff --git a/mobile/assets/icon/app_icon.png b/mobile/assets/icon/app_icon.png new file mode 100644 index 00000000..8f1e5b02 Binary files /dev/null and b/mobile/assets/icon/app_icon.png differ diff --git a/mobile/flutter_launcher_icons_ios.yaml b/mobile/flutter_launcher_icons_ios.yaml new file mode 100644 index 00000000..f0325439 --- /dev/null +++ b/mobile/flutter_launcher_icons_ios.yaml @@ -0,0 +1,5 @@ +# Used in CI for iOS build (run after flutter create --platforms=ios) +flutter_launcher_icons: + android: false + ios: true + image_path: "assets/icon/app_icon.png" diff --git a/mobile/ios/Flutter/Generated.xcconfig b/mobile/ios/Flutter/Generated.xcconfig new file mode 100644 index 00000000..b13b7642 --- /dev/null +++ b/mobile/ios/Flutter/Generated.xcconfig @@ -0,0 +1,14 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=C:\Flutter\flutter +FLUTTER_APPLICATION_PATH=C:\Users\dries\OneDrive\Dokumente\GitHub\TimeTracker\mobile +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib\main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=4.15.1 +FLUTTER_BUILD_NUMBER=1 +EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 +EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py b/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 00000000..a88caf99 --- /dev/null +++ b/mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/mobile/ios/Flutter/ephemeral/flutter_lldbinit b/mobile/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 00000000..e3ba6fbe --- /dev/null +++ b/mobile/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/mobile/ios/Flutter/flutter_export_environment.sh b/mobile/ios/Flutter/flutter_export_environment.sh new file mode 100644 index 00000000..2198fb62 --- /dev/null +++ b/mobile/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=C:\Flutter\flutter" +export "FLUTTER_APPLICATION_PATH=C:\Users\dries\OneDrive\Dokumente\GitHub\TimeTracker\mobile" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib\main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=4.15.1" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..fc612fde --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "filename" : "Icon-App-20x20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-60x60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-60x60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-20x20@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20x20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29x29@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40x40@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40x40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-76x76@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-76x76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-83.5x83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "Icon-App-1024x1024@1x.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..e69de29b diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..c2d98015 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..df931962 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..7d3dc140 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..f930fcff Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..548bde90 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..b25d703b Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..df931962 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..ef9a2b19 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..e6bf9d6f Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..50aea291 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..272123cb Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..a8460967 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..1752ff59 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..e6bf9d6f Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..741534db Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..236ba8e6 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..0a00012e Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..eccc554d Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..c82383a6 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0eabf389 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/Contents.json b/mobile/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.h b/mobile/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 00000000..7a890927 --- /dev/null +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 00000000..59340944 --- /dev/null +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,63 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import connectivity_plus; +#endif + +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import permission_handler_apple; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import workmanager_apple; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; + [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; +} + +@end diff --git a/mobile/lib/core/config/app_config.dart b/mobile/lib/core/config/app_config.dart index cfcae4a6..65bb58e3 100644 --- a/mobile/lib/core/config/app_config.dart +++ b/mobile/lib/core/config/app_config.dart @@ -8,6 +8,7 @@ class AppConfig { static const String syncIntervalKey = 'sync_interval'; static const String autoSyncKey = 'auto_sync'; static const String themeModeKey = 'theme_mode'; + static const String trustedInsecureHostsKey = 'trusted_insecure_hosts'; static const _storage = FlutterSecureStorage(); /// Get server URL from storage (synchronous getter for splash screen) @@ -63,12 +64,51 @@ class AppConfig { await prefs.remove(serverUrlKey); } + /// Get auto sync setting + static Future getAutoSync() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(autoSyncKey) ?? true; + } + /// Set auto sync setting static Future setAutoSync(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(autoSyncKey, value); } + /// Get sync interval (seconds) + static Future getSyncInterval() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(syncIntervalKey) ?? 60; + } + + /// Get theme mode + static Future getThemeMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(themeModeKey) ?? 'system'; + } + + /// Set theme mode ('system', 'light', 'dark') + static Future setThemeMode(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(themeModeKey, value); + } + + /// Get set of host names the user has chosen to trust (self-signed/invalid certs) + static Future> getTrustedInsecureHosts() async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(trustedInsecureHostsKey); + return list != null ? list.toSet() : {}; + } + + /// Add a host to the trusted insecure hosts set (user accepted the cert) + static Future addTrustedInsecureHost(String host) async { + final prefs = await SharedPreferences.getInstance(); + final set = await getTrustedInsecureHosts(); + set.add(host); + await prefs.setStringList(trustedInsecureHostsKey, set.toList()); + } + /// Clear all stored configuration static Future clear() async { final prefs = await SharedPreferences.getInstance(); @@ -76,6 +116,7 @@ class AppConfig { await prefs.remove(syncIntervalKey); await prefs.remove(autoSyncKey); await prefs.remove(themeModeKey); + await prefs.remove(trustedInsecureHostsKey); await _storage.delete(key: apiTokenKey); } diff --git a/mobile/lib/core/theme/app_theme.dart b/mobile/lib/core/theme/app_theme.dart index cc585b0c..a4522d8b 100644 --- a/mobile/lib/core/theme/app_theme.dart +++ b/mobile/lib/core/theme/app_theme.dart @@ -1,19 +1,102 @@ import 'package:flutter/material.dart'; +/// TimeTracker brand colors aligned with the webapp (brand-colors.css / tailwind.config.js). +class AppColors { + // Primary & secondary (webapp) + static const Color primary = Color(0xFF4A90E2); + static const Color primaryDark = Color(0xFF3B82F6); + static const Color secondary = Color(0xFF50E3C2); + static const Color secondaryDark = Color(0xFF06B6D4); + + // Light mode (webapp) + static const Color bgLight = Color(0xFFF7F9FB); + static const Color bgLightSecondary = Color(0xFFFFFFFF); + static const Color textLight = Color(0xFF2D3748); + static const Color textLightSecondary = Color(0xFFA0AEC0); + static const Color textLightMuted = Color(0xFF718096); + static const Color borderLight = Color(0xFFE2E8F0); + + // Dark mode (webapp) + static const Color bgDark = Color(0xFF1A202C); + static const Color bgDarkSecondary = Color(0xFF2D3748); + static const Color textDark = Color(0xFFE2E8F0); + static const Color textDarkSecondary = Color(0xFF718096); + static const Color textDarkMuted = Color(0xFFA0AEC0); + static const Color borderDark = Color(0xFF4A5568); + + // Status (webapp) + static const Color success = Color(0xFF4CAF50); + static const Color warning = Color(0xFFFF9800); + static const Color error = Color(0xFFE53935); + static const Color info = Color(0xFF2196F3); +} + class AppTheme { + /// Light color scheme matching the webapp (brand-colors.css, tailwind). + static ColorScheme get _lightColorScheme => ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + primaryContainer: AppColors.primary.withValues(alpha: 0.2), + onPrimaryContainer: AppColors.primary, + secondary: AppColors.secondary, + onSecondary: AppColors.textLight, + secondaryContainer: AppColors.secondary.withValues(alpha: 0.3), + onSecondaryContainer: AppColors.textLight, + tertiary: AppColors.secondaryDark, + onTertiary: Colors.white, + tertiaryContainer: AppColors.secondaryDark.withValues(alpha: 0.25), + onTertiaryContainer: AppColors.textLight, + error: AppColors.error, + onError: Colors.white, + errorContainer: AppColors.error.withValues(alpha: 0.15), + onErrorContainer: AppColors.error, + surface: AppColors.bgLight, + onSurface: AppColors.textLight, + onSurfaceVariant: AppColors.textLightMuted, + outline: AppColors.borderLight, + outlineVariant: AppColors.borderLight.withValues(alpha: 0.6), + surfaceContainerHighest: AppColors.bgLightSecondary, + surfaceContainerLowest: AppColors.bgLightSecondary, + ); + + /// Dark color scheme matching the webapp dark mode. + static ColorScheme get _darkColorScheme => ColorScheme.dark( + primary: AppColors.primary, + onPrimary: Colors.white, + primaryContainer: AppColors.primary.withValues(alpha: 0.3), + onPrimaryContainer: AppColors.textDark, + secondary: AppColors.secondary, + onSecondary: AppColors.textDark, + secondaryContainer: AppColors.secondary.withValues(alpha: 0.25), + onSecondaryContainer: AppColors.textDark, + tertiary: AppColors.secondaryDark, + onTertiary: Colors.white, + tertiaryContainer: AppColors.secondaryDark.withValues(alpha: 0.3), + onTertiaryContainer: AppColors.textDark, + error: AppColors.error, + onError: Colors.white, + errorContainer: AppColors.error.withValues(alpha: 0.25), + onErrorContainer: const Color(0xFFFCA5A5), + surface: AppColors.bgDark, + onSurface: AppColors.textDark, + onSurfaceVariant: AppColors.textDarkSecondary, + outline: AppColors.borderDark, + outlineVariant: AppColors.borderDark.withValues(alpha: 0.6), + surfaceContainerHighest: AppColors.bgDarkSecondary, + surfaceContainerLowest: AppColors.bgDarkSecondary, + ); + static ThemeData get lightTheme { return ThemeData( useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF2196F3), - brightness: Brightness.light, - ), + colorScheme: _lightColorScheme, appBarTheme: const AppBarTheme( centerTitle: true, elevation: 0, ), - cardTheme: CardTheme( + cardTheme: CardThemeData( elevation: 2, + color: AppColors.bgLightSecondary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -26,22 +109,52 @@ class AppTheme { ), ), ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: _lightColorScheme.primary, + foregroundColor: _lightColorScheme.onPrimary, + elevation: 2, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + contentTextStyle: const TextStyle(fontSize: 14), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + titleTextStyle: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + minLeadingWidth: 40, + ), ); } static ThemeData get darkTheme { return ThemeData( useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF2196F3), - brightness: Brightness.dark, - ), + colorScheme: _darkColorScheme, appBarTheme: const AppBarTheme( centerTitle: true, elevation: 0, ), - cardTheme: CardTheme( + cardTheme: CardThemeData( elevation: 2, + color: AppColors.bgDarkSecondary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -54,6 +167,36 @@ class AppTheme { ), ), ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + elevation: 2, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + contentTextStyle: const TextStyle(fontSize: 14), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + titleTextStyle: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + minLeadingWidth: 40, + ), ); } } diff --git a/mobile/lib/core/themes/app_theme.dart b/mobile/lib/core/themes/app_theme.dart deleted file mode 100644 index fb4a2315..00000000 --- a/mobile/lib/core/themes/app_theme.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppTheme { - // Color Scheme - static const Color primaryColor = Color(0xFF2196F3); - static const Color secondaryColor = Color(0xFF03A9F4); - static const Color errorColor = Color(0xFFE53935); - static const Color successColor = Color(0xFF4CAF50); - static const Color warningColor = Color(0xFFFF9800); - - // Light Theme - static ThemeData lightTheme = ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: primaryColor, - brightness: Brightness.light, - ), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardTheme( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - filled: true, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ); - - // Dark Theme - static ThemeData darkTheme = ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: primaryColor, - brightness: Brightness.dark, - ), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardTheme( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - filled: true, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ); -} diff --git a/mobile/lib/data/api/api_client.dart b/mobile/lib/data/api/api_client.dart index e3d1b169..61086338 100644 --- a/mobile/lib/data/api/api_client.dart +++ b/mobile/lib/data/api/api_client.dart @@ -1,22 +1,26 @@ import 'package:dio/dio.dart'; +import 'package:timetracker_mobile/utils/ssl/ssl_utils.dart'; + class ApiClient { final String baseUrl; late final Dio _dio; - String? _authToken; - ApiClient({required this.baseUrl}) { + ApiClient({ + required String baseUrl, + Set trustedInsecureHosts = const {}, + }) : baseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/' { _dio = Dio(BaseOptions( - baseUrl: baseUrl, + baseUrl: this.baseUrl, headers: { 'Content-Type': 'application/json', }, )); + configureDioTrustedHosts(_dio, trustedInsecureHosts); } /// Set authentication token Future setAuthToken(String token) async { - _authToken = token; _dio.options.headers['Authorization'] = 'Bearer $token'; } diff --git a/mobile/lib/data/local/background/workmanager_handler.dart b/mobile/lib/data/local/background/workmanager_handler.dart index dd7fbd0b..56254374 100644 --- a/mobile/lib/data/local/background/workmanager_handler.dart +++ b/mobile/lib/data/local/background/workmanager_handler.dart @@ -1,9 +1,7 @@ import 'package:workmanager/workmanager.dart'; import '../../api/api_client.dart'; -import '../../models/time_entry.dart'; import '../../../core/config/app_config.dart'; import '../../../utils/auth/auth_service.dart'; -import 'dart:convert'; @pragma('vm:entry-point') void callbackDispatcher() { @@ -35,8 +33,8 @@ Future _updateTimerStatus() async { final apiClient = ApiClient(baseUrl: serverUrl); await apiClient.setAuthToken(token); - final response = await apiClient.getTimerStatus(); - if (response.statusCode == 200 && response.data['active'] == true) { + final data = await apiClient.getTimerStatus(); + if (data['active'] == true) { // Timer is still running, could update local notification return true; } @@ -74,7 +72,7 @@ Future _syncData() async { class WorkManagerService { static Future initialize() async { - await Workmanager().initialize(callbackDispatcher, isInDebugMode: false); + await Workmanager().initialize(callbackDispatcher); } static Future startTimerStatusUpdates() async { diff --git a/mobile/lib/data/local/database/sync_service.dart b/mobile/lib/data/local/database/sync_service.dart index 0c40053a..9fe230ba 100644 --- a/mobile/lib/data/local/database/sync_service.dart +++ b/mobile/lib/data/local/database/sync_service.dart @@ -1,9 +1,9 @@ -import '../../../core/constants/app_constants.dart'; +import 'dart:convert'; + import '../database/hive_service.dart'; import '../../api/api_client.dart'; import '../../models/time_entry.dart'; import '../../models/project.dart'; -import '../../models/task.dart'; class SyncQueueItem { final String id; @@ -96,15 +96,33 @@ class SyncService { } Future _syncTimeEntry(SyncQueueItem item) async { + final d = item.data; switch (item.action) { case 'create': - await apiClient.createTimeEntry(item.data); + await apiClient.createTimeEntry( + projectId: d['project_id'] as int, + startTime: d['start_time'] as String, + taskId: d['task_id'] as int?, + endTime: d['end_time'] as String?, + notes: d['notes'] as String?, + tags: d['tags'] as String?, + billable: d['billable'] as bool?, + ); break; case 'update': - await apiClient.updateTimeEntry(item.data['id'], item.data); + await apiClient.updateTimeEntry( + d['id'] as int, + projectId: d['project_id'] as int?, + taskId: d['task_id'] as int?, + startTime: d['start_time'] as String?, + endTime: d['end_time'] as String?, + notes: d['notes'] as String?, + tags: d['tags'] as String?, + billable: d['billable'] as bool?, + ); break; case 'delete': - await apiClient.deleteTimeEntry(item.data['id']); + await apiClient.deleteTimeEntry(d['id'] as int); break; } } @@ -123,13 +141,11 @@ class SyncService { Future syncFromServer() async { try { // Sync projects - final projectsResponse = await apiClient.getProjects(status: 'active'); - if (projectsResponse.statusCode == 200) { - final projects = (projectsResponse.data['projects'] as List) - .map((json) => Project.fromJson(json)) - .toList(); - - for (final project in projects) { + final projectsData = await apiClient.getProjects(status: 'active'); + final projectsList = projectsData['projects'] as List?; + if (projectsList != null) { + for (final json in projectsList) { + final project = Project.fromJson(Map.from(json as Map)); await HiveService.projectsBox.put(project.id, project.toJson()); } } @@ -137,17 +153,14 @@ class SyncService { // Sync time entries (recent ones) final now = DateTime.now(); final startDate = now.subtract(const Duration(days: 30)); - final entriesResponse = await apiClient.getTimeEntries( + final entriesData = await apiClient.getTimeEntries( startDate: startDate.toIso8601String().split('T')[0], endDate: now.toIso8601String().split('T')[0], ); - - if (entriesResponse.statusCode == 200) { - final entries = (entriesResponse.data['time_entries'] as List) - .map((json) => TimeEntry.fromJson(json)) - .toList(); - - for (final entry in entries) { + final entriesList = entriesData['time_entries'] as List?; + if (entriesList != null) { + for (final json in entriesList) { + final entry = TimeEntry.fromJson(Map.from(json as Map)); await HiveService.timeEntriesBox.put(entry.id, entry.toJson()); } } @@ -166,8 +179,8 @@ class SyncService { if (value is Map) { return Project.fromJson(Map.from(value)); } else if (value is String) { - // If stored as string, parse it (though Hive usually stores as Map) - return Project.fromJson(Map.from(value)); + return Project.fromJson( + Map.from(jsonDecode(value) as Map)); } throw Exception('Invalid project data format'); }) @@ -189,22 +202,33 @@ class SyncService { if (value is Map) { return TimeEntry.fromJson(Map.from(value)); } else if (value is String) { - return TimeEntry.fromJson(Map.from(value)); + return TimeEntry.fromJson( + Map.from(jsonDecode(value) as Map)); } throw Exception('Invalid time entry data format'); }) .toList(); - if (startDate != null) { - entries = entries.where((e) => e.startTime.isAfter(startDate)).toList(); - } - if (endDate != null) { - entries = entries.where((e) => e.startTime.isBefore(endDate)).toList(); - } - if (projectId != null) { - entries = entries.where((e) => e.projectId == projectId).toList(); - } + if (startDate != null) { + entries = entries + .where((e) => + e.startTime != null && e.startTime!.isAfter(startDate)) + .toList(); + } + if (endDate != null) { + entries = entries + .where((e) => + e.startTime != null && e.startTime!.isBefore(endDate)) + .toList(); + } + if (projectId != null) { + entries = + entries.where((e) => e.projectId == projectId).toList(); + } - return entries; + return entries; + } catch (e) { + return []; + } } } diff --git a/mobile/lib/data/models/project.dart b/mobile/lib/data/models/project.dart index b32f2c62..3f0c05f6 100644 --- a/mobile/lib/data/models/project.dart +++ b/mobile/lib/data/models/project.dart @@ -28,4 +28,16 @@ class Project { updatedAt: DateTime.parse(json['updated_at'] as String), ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'client': client, + 'status': status, + 'billable': billable, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } } diff --git a/mobile/lib/domain/repositories/time_tracking_repository.dart b/mobile/lib/domain/repositories/time_tracking_repository.dart index f97c9053..027f421e 100644 --- a/mobile/lib/domain/repositories/time_tracking_repository.dart +++ b/mobile/lib/domain/repositories/time_tracking_repository.dart @@ -1,4 +1,5 @@ import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; import 'package:timetracker_mobile/data/api/api_client.dart'; import 'package:timetracker_mobile/data/models/timer.dart'; import 'package:timetracker_mobile/data/models/time_entry.dart'; @@ -7,6 +8,14 @@ import 'package:timetracker_mobile/data/models/task.dart'; import 'package:timetracker_mobile/data/storage/local_storage.dart'; import 'package:timetracker_mobile/data/storage/sync_service.dart'; +/// Thrown when stop timer returns 400 because the timer was already stopped +class TimerAlreadyStoppedException implements Exception { + final String message; + TimerAlreadyStoppedException(this.message); + @override + String toString() => message; +} + /// Repository for time tracking operations class TimeTrackingRepository { final ApiClient? apiClient; @@ -106,7 +115,23 @@ class TimeTrackingRepository { } try { final response = await apiClient!.stopTimer(); - return TimeEntry.fromJson(response['time_entry'] as Map); + final entry = TimeEntry.fromJson(response['time_entry'] as Map); + await LocalStorage.clearTimer(); + return entry; + } on DioException catch (e) { + if (e.response?.statusCode == 400) { + final data = e.response?.data; + final errorCode = data is Map ? data['error_code'] as String? : null; + final errorMsg = data is Map ? data['error'] as String? : null; + await LocalStorage.clearTimer(); + if (errorCode == 'no_active_timer' || errorCode == 'timer_already_stopped') { + throw TimerAlreadyStoppedException( + errorMsg ?? 'Timer was already stopped', + ); + } + throw Exception(errorMsg ?? 'Failed to stop timer'); + } + rethrow; } catch (e) { throw Exception('Failed to stop timer: $e'); } diff --git a/mobile/lib/domain/usecases/sync_usecase.dart b/mobile/lib/domain/usecases/sync_usecase.dart index e6f096a6..0dc22833 100644 --- a/mobile/lib/domain/usecases/sync_usecase.dart +++ b/mobile/lib/domain/usecases/sync_usecase.dart @@ -6,20 +6,19 @@ import '../../utils/auth/auth_service.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; class SyncUseCase { - final SyncService _syncService; final Connectivity _connectivity = Connectivity(); Timer? _syncTimer; - SyncUseCase(this._syncService); + SyncUseCase(); // Start periodic sync if auto-sync is enabled - void startPeriodicSync() { - if (!AppConfig.autoSync) return; + Future startPeriodicSync() async { + final autoSync = await AppConfig.getAutoSync(); + if (!autoSync) return; _syncTimer?.cancel(); - final interval = Duration(seconds: AppConfig.syncInterval); - - _syncTimer = Timer.periodic(interval, (timer) async { + final intervalSeconds = await AppConfig.getSyncInterval(); + _syncTimer = Timer.periodic(Duration(seconds: intervalSeconds), (timer) async { if (await _isOnline()) { await sync(); } @@ -41,11 +40,10 @@ class SyncUseCase { // Full sync: process queue and sync from server Future sync() async { try { - // Ensure we have API client - final serverUrl = AppConfig.serverUrl; + final serverUrl = await AppConfig.getServerUrl(); final token = await AuthService.getToken(); - if (serverUrl == null || token == null) { + if (serverUrl == null || serverUrl.isEmpty || token == null || token.isEmpty) { return false; } @@ -54,15 +52,11 @@ class SyncUseCase { final syncService = SyncService(apiClient); - // Process sync queue (offline operations) await syncService.processQueue(); - - // Sync from server (get latest data) await syncService.syncFromServer(); return true; - } catch (e) { - print('Sync error: $e'); + } catch (_) { return false; } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3d842953..04390d39 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:timetracker_mobile/core/constants/app_constants.dart'; +import 'package:timetracker_mobile/core/theme/app_theme.dart'; import 'package:timetracker_mobile/data/storage/local_storage.dart'; +import 'package:timetracker_mobile/presentation/providers/theme_mode_provider.dart'; import 'package:timetracker_mobile/presentation/screens/splash_screen.dart'; import 'package:timetracker_mobile/presentation/screens/login_screen.dart'; import 'package:timetracker_mobile/presentation/screens/home_screen.dart'; @@ -16,17 +18,18 @@ void main() async { ); } -class TimeTrackerApp extends StatelessWidget { +class TimeTrackerApp extends ConsumerWidget { const TimeTrackerApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final themeModeString = ref.watch(themeModeProvider); + final themeMode = themeModeFromString(themeModeString); return MaterialApp( title: 'TimeTracker', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - useMaterial3: true, - ), + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, initialRoute: AppConstants.routeSplash, routes: { AppConstants.routeSplash: (context) => const SplashScreen(), diff --git a/mobile/lib/presentation/providers/api_provider.dart b/mobile/lib/presentation/providers/api_provider.dart index 9d6cc9e8..cb30e036 100644 --- a/mobile/lib/presentation/providers/api_provider.dart +++ b/mobile/lib/presentation/providers/api_provider.dart @@ -1,14 +1,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:timetracker_mobile/core/config/app_config.dart'; import 'package:timetracker_mobile/data/api/api_client.dart'; +import 'package:timetracker_mobile/utils/auth/auth_service.dart'; -/// Provider for API client +/// Provider for API client (authenticated when token is present) final apiClientProvider = FutureProvider((ref) async { final serverUrl = await AppConfig.getServerUrl(); if (serverUrl == null || serverUrl.isEmpty) { return null; } - return ApiClient(baseUrl: serverUrl); + final token = await AuthService.getToken(); + final trustedHosts = await AppConfig.getTrustedInsecureHosts(); + final client = ApiClient(baseUrl: serverUrl, trustedInsecureHosts: trustedHosts); + if (token != null && token.isNotEmpty) { + await client.setAuthToken(token); + } + return client; }); /// Provider for API client (synchronous, requires server URL) diff --git a/mobile/lib/presentation/providers/theme_mode_provider.dart b/mobile/lib/presentation/providers/theme_mode_provider.dart new file mode 100644 index 00000000..1ea074c0 --- /dev/null +++ b/mobile/lib/presentation/providers/theme_mode_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:timetracker_mobile/core/config/app_config.dart'; + +/// Notifier that holds the current theme mode string ('system', 'light', 'dark') +/// and loads the saved value on startup. +class ThemeModeNotifier extends StateNotifier { + ThemeModeNotifier() : super('system') { + _load(); + } + + void _load() { + AppConfig.getThemeMode().then((value) { + state = value; + }); + } + + Future setMode(String value) async { + await AppConfig.setThemeMode(value); + state = value; + } +} + +final themeModeProvider = + StateNotifierProvider((ref) => ThemeModeNotifier()); + +/// Maps stored string to Flutter's ThemeMode. +ThemeMode themeModeFromString(String value) { + switch (value) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + default: + return ThemeMode.system; + } +} diff --git a/mobile/lib/presentation/providers/timer_provider.dart b/mobile/lib/presentation/providers/timer_provider.dart index ecb7e99f..b736ff33 100644 --- a/mobile/lib/presentation/providers/timer_provider.dart +++ b/mobile/lib/presentation/providers/timer_provider.dart @@ -29,11 +29,13 @@ class TimerState { Timer? timer, bool? isLoading, String? error, + bool clearTimer = false, + bool clearError = false, }) { return TimerState( - timer: timer ?? this.timer, + timer: clearTimer ? null : (timer ?? this.timer), isLoading: isLoading ?? this.isLoading, - error: error, + error: clearError ? null : (error ?? this.error), ); } @@ -67,7 +69,7 @@ class TimerNotifier extends StateNotifier { if (repository == null) return; try { - state = state.copyWith(isLoading: true, error: null); + state = state.copyWith(isLoading: true, clearError: true); final timer = await repository!.getTimerStatus(); state = state.copyWith(timer: timer, isLoading: false); } catch (e) { @@ -86,7 +88,7 @@ class TimerNotifier extends StateNotifier { } try { - state = state.copyWith(isLoading: true, error: null); + state = state.copyWith(isLoading: true, clearError: true); final timer = await repository!.startTimer( projectId: projectId, taskId: taskId, @@ -107,9 +109,11 @@ class TimerNotifier extends StateNotifier { } try { - state = state.copyWith(isLoading: true, error: null); + state = state.copyWith(isLoading: true, clearError: true); await repository!.stopTimer(); - state = state.copyWith(timer: null, isLoading: false); + state = state.copyWith(clearTimer: true, isLoading: false, clearError: true); + } on TimerAlreadyStoppedException catch (e) { + state = state.copyWith(clearTimer: true, isLoading: false, error: e.message); } catch (e) { state = state.copyWith(isLoading: false, error: e.toString()); } @@ -121,7 +125,7 @@ class TimerNotifier extends StateNotifier { /// Get elapsed time for active timer Duration getElapsedTime() { - if (state.timer == null || state.timer!.startTime == null) { + if (state.timer == null) { return Duration.zero; } return DateTime.now().difference(state.timer!.startTime); diff --git a/mobile/lib/presentation/screens/home_screen.dart b/mobile/lib/presentation/screens/home_screen.dart index bb93f711..d18d93d1 100644 --- a/mobile/lib/presentation/screens/home_screen.dart +++ b/mobile/lib/presentation/screens/home_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../core/constants/app_constants.dart'; +import 'package:timetracker_mobile/data/models/project.dart'; import '../providers/timer_provider.dart'; import '../providers/time_entries_provider.dart'; +import '../providers/projects_provider.dart'; +import '../widgets/empty_state.dart'; import 'timer_screen.dart'; import 'projects_screen.dart'; import 'time_entries_screen.dart'; @@ -100,6 +102,7 @@ class _DashboardTabState extends ConsumerState { // Load data on init WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(timerProvider.notifier).checkTimerStatus(); + ref.read(projectsProvider.notifier).loadProjects(); final now = DateTime.now(); ref.read(timeEntriesProvider.notifier).loadTimeEntries( startDate: now.toIso8601String().split('T')[0], @@ -114,10 +117,22 @@ class _DashboardTabState extends ConsumerState { super.dispose(); } + String _projectName(int? projectId, List projects) { + if (projectId == null) return 'Unknown project'; + try { + final p = projects.firstWhere((p) => p.id == projectId); + return p.name; + } catch (_) { + return 'Unknown project'; + } + } + @override Widget build(BuildContext context) { final timerState = ref.watch(timerProvider); final entriesState = ref.watch(timeEntriesProvider); + final projectsState = ref.watch(projectsProvider); + final theme = Theme.of(context); // Calculate today's total final todayTotal = entriesState.entries.fold( @@ -173,7 +188,7 @@ class _DashboardTabState extends ConsumerState { Text( 'Tap to view details', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -182,7 +197,7 @@ class _DashboardTabState extends ConsumerState { Text( 'No active timer', style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.grey, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -207,8 +222,11 @@ class _DashboardTabState extends ConsumerState { entriesState.isLoading ? 'Loading...' : '${hours}h ${minutes}m', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( + style: theme.textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, + color: entriesState.isLoading + ? theme.colorScheme.onSurfaceVariant + : null, ), ), ], @@ -224,24 +242,33 @@ class _DashboardTabState extends ConsumerState { if (entriesState.isLoading) const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator())) else if (entriesState.entries.isEmpty) - const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: Text('No recent entries'), - ), + const EmptyState( + icon: Icons.history, + title: 'No recent entries', + subtitle: 'Start a timer or add a time entry to see them here.', ) else - ...entriesState.entries.take(5).map((entry) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: CircleAvatar( - child: Icon(Icons.timer), - ), - title: Text(entry.projectId?.toString() ?? 'Unknown Project'), - subtitle: Text(entry.notes ?? ''), - trailing: Text(entry.formattedDuration), + ...entriesState.entries.take(5).map((entry) { + final projectName = _projectName(entry.projectId, projectsState.projects); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + child: Icon(Icons.timer), ), - )), + title: Text( + projectName, + style: projectName == 'Unknown project' + ? theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ) + : null, + ), + subtitle: Text(entry.notes ?? ''), + trailing: Text(entry.formattedDuration), + ), + ); + }), ], ), ), diff --git a/mobile/lib/presentation/screens/login_screen.dart b/mobile/lib/presentation/screens/login_screen.dart index 0c5787e9..7d3eeb2f 100644 --- a/mobile/lib/presentation/screens/login_screen.dart +++ b/mobile/lib/presentation/screens/login_screen.dart @@ -1,9 +1,12 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:timetracker_mobile/core/config/app_config.dart'; +import 'package:timetracker_mobile/core/constants/app_constants.dart'; import 'package:timetracker_mobile/data/api/api_client.dart'; -import 'package:timetracker_mobile/presentation/screens/timer_screen.dart'; +import 'package:timetracker_mobile/utils/ssl/certificate_error.dart'; +import 'package:timetracker_mobile/utils/ssl/ssl_utils.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -15,7 +18,8 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); final _serverUrlController = TextEditingController(); - final _apiTokenController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); final _storage = const FlutterSecureStorage(); bool _isLoading = false; String? _error; @@ -36,10 +40,30 @@ class _LoginScreenState extends ConsumerState { @override void dispose() { _serverUrlController.dispose(); - _apiTokenController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); super.dispose(); } + /// True if host is likely local/emulator (self-signed cert is common). + static bool _isLikelyLocalOrEmulator(String host) { + final h = host.toLowerCase(); + if (h == '10.0.2.2' || h == 'localhost' || h == '127.0.0.1') return true; + if (h.startsWith('192.168.') || h.startsWith('10.') || h.startsWith('172.')) return true; + return false; + } + + /// Normalize server URL: add https:// if no scheme, then ensure valid base. + static String _normalizeServerUrl(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return trimmed; + if (trimmed.toLowerCase().startsWith('https://') || + trimmed.toLowerCase().startsWith('http://')) { + return trimmed; + } + return 'https://$trimmed'; + } + Future _handleLogin() async { if (!_formKey.currentState!.validate()) { return; @@ -51,47 +75,132 @@ class _LoginScreenState extends ConsumerState { }); try { - final serverUrl = _serverUrlController.text.trim(); - final apiToken = _apiTokenController.text.trim(); + final rawUrl = _serverUrlController.text.trim(); + final serverUrl = _normalizeServerUrl(rawUrl); + final username = _usernameController.text.trim(); + final password = _passwordController.text; - // Validate token format - if (!apiToken.startsWith('tt_')) { + final baseUrl = serverUrl.endsWith('/') ? serverUrl : '$serverUrl/'; + final trustedHosts = await AppConfig.getTrustedInsecureHosts(); + final dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: {'Content-Type': 'application/json'}, + )); + configureDioTrustedHosts(dio, trustedHosts); + + final response = await dio.post>( + '/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( + 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 { 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 { 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 { 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), ), ), ], diff --git a/mobile/lib/presentation/screens/projects_screen.dart b/mobile/lib/presentation/screens/projects_screen.dart index 5202b065..e181997c 100644 --- a/mobile/lib/presentation/screens/projects_screen.dart +++ b/mobile/lib/presentation/screens/projects_screen.dart @@ -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 { 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, diff --git a/mobile/lib/presentation/screens/settings_screen.dart b/mobile/lib/presentation/screens/settings_screen.dart index a67b4512..79b5fe76 100644 --- a/mobile/lib/presentation/screens/settings_screen.dart +++ b/mobile/lib/presentation/screens/settings_screen.dart @@ -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 { + bool _isLoading = true; + String? _serverUrl; + int _syncInterval = 60; + bool _autoSync = true; + String _version = 'β€”'; + + @override + void initState() { + super.initState(); + _loadConfig(); + } + + Future _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( + 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 { // 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( context: context, @@ -113,12 +176,11 @@ class _SettingsScreenState extends ConsumerState { ), 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 { ), ); } + + 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(); + }, + ); + } } diff --git a/mobile/lib/presentation/screens/splash_screen.dart b/mobile/lib/presentation/screens/splash_screen.dart index e097d1b5..bbe5bfa3 100644 --- a/mobile/lib/presentation/screens/splash_screen.dart +++ b/mobile/lib/presentation/screens/splash_screen.dart @@ -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 { _checkAuthStatus(); } - Future _checkAuthStatus() async { - await Future.delayed(const Duration(seconds: 2)); - - if (!mounted) return; - + void _checkAuthStatus() { + _continueAuthCheck(); + } + + Future _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 { const CircularProgressIndicator(), ], ), + ), ), ); } diff --git a/mobile/lib/presentation/screens/time_entries_screen.dart b/mobile/lib/presentation/screens/time_entries_screen.dart index 82f2cac4..1c96cc05 100644 --- a/mobile/lib/presentation/screens/time_entries_screen.dart +++ b/mobile/lib/presentation/screens/time_entries_screen.dart @@ -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 { } 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 { 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'), ), diff --git a/mobile/lib/presentation/screens/time_entry_form_screen.dart b/mobile/lib/presentation/screens/time_entry_form_screen.dart index 2695e5af..db4efb0b 100644 --- a/mobile/lib/presentation/screens/time_entry_form_screen.dart +++ b/mobile/lib/presentation/screens/time_entry_form_screen.dart @@ -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 { 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 { labelText: 'Task (Optional)', prefixIcon: Icon(Icons.task), ), - value: _selectedTaskId, + initialValue: _selectedTaskId != null && + tasksState.tasks.any((t) => t.id == _selectedTaskId) + ? _selectedTaskId + : null, items: [ const DropdownMenuItem( value: null, diff --git a/mobile/lib/presentation/screens/timer_screen.dart b/mobile/lib/presentation/screens/timer_screen.dart index 71dde4f5..f3641e35 100644 --- a/mobile/lib/presentation/screens/timer_screen.dart +++ b/mobile/lib/presentation/screens/timer_screen.dart @@ -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 { @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 { icon: const Icon(Icons.logout), onPressed: _handleLogout, tooltip: 'Logout', + style: IconButton.styleFrom(foregroundColor: colorScheme.error), ), ], ), @@ -60,6 +63,7 @@ class _TimerScreenState extends ConsumerState { } Future _handleLogout() async { + final colorScheme = Theme.of(context).colorScheme; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -70,33 +74,46 @@ class _TimerScreenState extends ConsumerState { 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( diff --git a/mobile/lib/presentation/widgets/empty_state.dart b/mobile/lib/presentation/widgets/empty_state.dart new file mode 100644 index 00000000..b8478cd7 --- /dev/null +++ b/mobile/lib/presentation/widgets/empty_state.dart @@ -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!, + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/error_view.dart b/mobile/lib/presentation/widgets/error_view.dart new file mode 100644 index 00000000..1876d20e --- /dev/null +++ b/mobile/lib/presentation/widgets/error_view.dart @@ -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'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/time_entry_card.dart b/mobile/lib/presentation/widgets/time_entry_card.dart index 145a1d44..f995f77f 100644 --- a/mobile/lib/presentation/widgets/time_entry_card.dart +++ b/mobile/lib/presentation/widgets/time_entry_card.dart @@ -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, ), ), ], diff --git a/mobile/lib/presentation/widgets/timer_widget.dart b/mobile/lib/presentation/widgets/timer_widget.dart index 40f2156d..adb1d721 100644 --- a/mobile/lib/presentation/widgets/timer_widget.dart +++ b/mobile/lib/presentation/widgets/timer_widget.dart @@ -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 { 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 { 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 { 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 { : _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 { 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( + 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 { // Task selection (optional) if (_selectedProjectId != null) DropdownButtonFormField( + 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( value: null, @@ -294,17 +339,44 @@ class _StartTimerDialogState extends ConsumerState { ), 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'), ), ], ); diff --git a/mobile/lib/utils/ssl/certificate_error.dart b/mobile/lib/utils/ssl/certificate_error.dart new file mode 100644 index 00000000..b79dc45f --- /dev/null +++ b/mobile/lib/utils/ssl/certificate_error.dart @@ -0,0 +1 @@ +export 'certificate_error_io.dart' if (dart.library.io) 'certificate_error_stub.dart'; diff --git a/mobile/lib/utils/ssl/certificate_error_io.dart b/mobile/lib/utils/ssl/certificate_error_io.dart new file mode 100644 index 00000000..e4db107a --- /dev/null +++ b/mobile/lib/utils/ssl/certificate_error_io.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; +} diff --git a/mobile/lib/utils/ssl/certificate_error_stub.dart b/mobile/lib/utils/ssl/certificate_error_stub.dart new file mode 100644 index 00000000..47b398e1 --- /dev/null +++ b/mobile/lib/utils/ssl/certificate_error_stub.dart @@ -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'); +} diff --git a/mobile/lib/utils/ssl/ssl_utils.dart b/mobile/lib/utils/ssl/ssl_utils.dart new file mode 100644 index 00000000..bf62e843 --- /dev/null +++ b/mobile/lib/utils/ssl/ssl_utils.dart @@ -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 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; + }; + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock new file mode 100644 index 00000000..932d1444 --- /dev/null +++ b/mobile/pubspec.lock @@ -0,0 +1,1106 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + url: "https://pub.dev" + source: hosted + version: "8.12.3" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + url: "https://pub.dev" + source: hosted + version: "9.2.5" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + url: "https://pub.dev" + source: hosted + version: "2.4.20" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "1e3521a3e6d3fc7f645a58b135ab663d458ab12504f1ea7f9b4b81d47086c478" + url: "https://pub.dev" + source: hosted + version: "3.0.9" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e058bcd8..e15498a8 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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" diff --git a/mobile/test/api_client_test.dart b/mobile/test/api_client_test.dart index 9d9f675b..f2104b98 100644 --- a/mobile/test/api_client_test.dart +++ b/mobile/test/api_client_test.dart @@ -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', () { diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 64747d7a..83ce075f 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -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() {