Connect Flutter phone auth and Android platform
This commit is contained in:
+6
-3
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
|
||||
revision: "ee80f08bbf97172ec030b8751ceab557177a34a6"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,11 +13,14 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
- platform: ios
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: android
|
||||
create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
+15
-2
@@ -1,3 +1,16 @@
|
||||
# fitness_tracker
|
||||
# SportPath mobile
|
||||
|
||||
A new Flutter project.
|
||||
تطبيق Flutter عربي لحفظ جلسات المشي والجري محليًا، تسجيل الدخول برقم الهاتف، ومزامنة التمارين إلى PHP API. يحتوي المشروع الآن منصتي iOS وAndroid؛ `android/` يستخدم معرّف تطوير مؤقتًا `com.example.fitness_tracker` ويجب استبداله بمعرّف الحزمة الذي سيعتمده مالك المنتج قبل النشر على المتاجر.
|
||||
|
||||
## عنوان خادم API
|
||||
|
||||
لا يوجد عنوان إنتاج افتراضي داخل التطبيق. مرّر نطاقًا حقيقيًا يعمل عبر HTTPS وقت البناء:
|
||||
|
||||
```bash
|
||||
flutter build apk --dart-define=API_BASE_URL=https://fitness.example.com
|
||||
flutter build ios --dart-define=API_BASE_URL=https://fitness.example.com
|
||||
```
|
||||
|
||||
يستخدم التطبيق مسارات `API_BASE_URL/api/v1/auth/` للمصادقة و`API_BASE_URL/api/v1/workouts.php` لمزامنة سجل التمرين. لا تضع مفاتيح OTP أو JWT أو قواعد البيانات في `--dart-define`؛ هذه تبقى على الخادم. يلزم إعداد مزود OTP وتطبيق migrations على قاعدة staging قبل نجاح تسجيل الدخول.
|
||||
|
||||
جلسة التطبيق تحتفظ بـrefresh token في التخزين الآمن للنظام وتحدّث access token تلقائيًا عند استجابة 401. بيانات GPS والتمارين تبقى محليًا في SQLite/outbox إلى حين تسجيل الدخول والاتصال. لا تحذف التطبيق أو بياناته أثناء المزامنة إن كان لديك سجل غير متزامن.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/.gradle
|
||||
/captures/
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.fitness_tracker"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.fitness_tracker"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,51 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:label="SportPath"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.fitness_tracker
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+90
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
@@ -27,6 +28,7 @@ class WorkoutController extends GetxController {
|
||||
final RxDouble liveSpeed = 0.0.obs;
|
||||
|
||||
Timer? _timer;
|
||||
Future<void> _coordinateWriteQueue = Future<void>.value();
|
||||
|
||||
// ─── التحكم في الجلسة ──────────────────────────────────────────
|
||||
|
||||
@@ -42,6 +44,7 @@ class WorkoutController extends GetxController {
|
||||
currentWorkout.value!.start();
|
||||
|
||||
liveCoordinates.clear();
|
||||
_coordinateWriteQueue = Future<void>.value();
|
||||
elapsedSeconds.value = 0;
|
||||
liveDistance.value = 0;
|
||||
liveSpeed.value = 0;
|
||||
@@ -75,6 +78,7 @@ class WorkoutController extends GetxController {
|
||||
|
||||
await _gps.stopTracking();
|
||||
_timer?.cancel();
|
||||
await _coordinateWriteQueue;
|
||||
|
||||
final workout = currentWorkout.value!;
|
||||
workout.stop();
|
||||
@@ -101,7 +105,12 @@ class WorkoutController extends GetxController {
|
||||
await _sync.queueWorkout(workout);
|
||||
|
||||
currentWorkout.value = null;
|
||||
Get.snackbar("تم الحفظ", "تم حفظ التمرين بنجاح وجاري المزامنة...");
|
||||
Get.snackbar(
|
||||
"تم الحفظ",
|
||||
workout.coordinates.length >= 2
|
||||
? "تم حفظ التمرين محليًا وجاري المزامنة عند توفر الاتصال."
|
||||
: "حُفظ النشاط على الجهاز؛ لم تتوفر نقاط GPS كافية لمزامنته.",
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNewCoordinate(GpsCoordinate coord) {
|
||||
@@ -111,7 +120,12 @@ class WorkoutController extends GetxController {
|
||||
liveSpeed.value = coord.speed ?? 0;
|
||||
|
||||
// حفظ فوري في DB (للحماية من انهيار التطبيق)
|
||||
_db.saveCoordinate(currentWorkout.value!.id, coord);
|
||||
final workoutId = currentWorkout.value!.id;
|
||||
_coordinateWriteQueue = _coordinateWriteQueue.then((_) async {
|
||||
await _db.saveCoordinate(workoutId, coord);
|
||||
}).catchError((Object error, StackTrace stackTrace) {
|
||||
debugPrint('[TRACKING] Coordinate persistence failed: $error');
|
||||
});
|
||||
|
||||
// حساب المسافة التراكمية حياً
|
||||
if (liveCoordinates.length > 1) {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'services/database_service.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/gps_service.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/analytics_service.dart';
|
||||
import 'controllers/workout_controller.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/phone_login_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 1. تهيئة الخدمات (Services Initialization)
|
||||
// بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل
|
||||
// بالترتيب: قاعدة البيانات -> الهوية -> GPS -> المزامنة -> التحليل
|
||||
await Get.putAsync<DatabaseService>(() => DatabaseService().init());
|
||||
await Get.putAsync<AuthService>(() => AuthService().init());
|
||||
Get.put(GpsService());
|
||||
await Get.putAsync<SyncService>(() => SyncService().init());
|
||||
Get.put(AnalyticsService());
|
||||
@@ -49,7 +52,9 @@ class SportPathApp extends StatelessWidget {
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
home: Get.find<AuthService>().isSignedIn
|
||||
? const HomeScreen()
|
||||
: const PhoneLoginScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import '../controllers/workout_controller.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../screens/history_screen.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import 'phone_login_screen.dart';
|
||||
|
||||
const _ink = Color(0xFF17251E);
|
||||
const _green = Color(0xFF456C52);
|
||||
@@ -73,6 +75,23 @@ class HomeScreen extends StatelessWidget {
|
||||
const TextStyle(color: _ink, fontSize: 11)),
|
||||
]),
|
||||
)),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'الحساب',
|
||||
icon: const Icon(Icons.account_circle_outlined,
|
||||
color: _green, size: 25),
|
||||
onSelected: (value) async {
|
||||
if (value == 'logout') {
|
||||
await Get.find<AuthService>().logout();
|
||||
Get.offAll(() => const PhoneLoginScreen());
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: Text('تسجيل الخروج'),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 34),
|
||||
Text(_arabicToday(),
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
const _loginInk = Color(0xFF17251E);
|
||||
const _loginGreen = Color(0xFF456C52);
|
||||
|
||||
class PhoneLoginScreen extends StatefulWidget {
|
||||
const PhoneLoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PhoneLoginScreen> createState() => _PhoneLoginScreenState();
|
||||
}
|
||||
|
||||
class _PhoneLoginScreenState extends State<PhoneLoginScreen> {
|
||||
final _phone = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _name = TextEditingController();
|
||||
bool _registration = false;
|
||||
bool _codeSent = false;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_code.dispose();
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _requestCode() async {
|
||||
final phone = _phone.text.trim();
|
||||
if (!RegExp(r'^\+[1-9][0-9]{7,14}$').hasMatch(phone)) {
|
||||
_showMessage('اكتب رقمك بصيغة دولية، مثل +9627XXXXXXXX.');
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await Get.find<AuthService>().requestOtp(
|
||||
phoneE164: phone,
|
||||
isRegistration: _registration,
|
||||
);
|
||||
if (mounted) setState(() => _codeSent = true);
|
||||
_showMessage('أرسلنا رمز التحقق إذا كانت الخدمة مفعّلة.');
|
||||
} catch (error) {
|
||||
_showMessage(error.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyCode() async {
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(_code.text.trim())) {
|
||||
_showMessage('أدخل رمز التحقق المكوّن من ٦ أرقام.');
|
||||
return;
|
||||
}
|
||||
if (_registration && _name.text.trim().isEmpty) {
|
||||
_showMessage('اكتب الاسم الذي تود ظهوره في حسابك.');
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await Get.find<AuthService>().verifyOtp(
|
||||
phoneE164: _phone.text.trim(),
|
||||
code: _code.text.trim(),
|
||||
isRegistration: _registration,
|
||||
displayName: _name.text.trim().isEmpty ? null : _name.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
Get.offAll(() => const HomeScreen());
|
||||
Get.find<SyncService>().syncPendingWorkouts(retryNow: true);
|
||||
}
|
||||
} catch (error) {
|
||||
_showMessage(error.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = Get.find<AuthService>();
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7F2),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 28),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _loginGreen,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('S',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: 'Georgia',
|
||||
fontSize: 27,
|
||||
fontStyle: FontStyle.italic)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const Text('أهلًا بك في SportPath',
|
||||
style: TextStyle(
|
||||
color: _loginGreen,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 9),
|
||||
Text(
|
||||
_codeSent ? 'تحقق من رقمك.' : 'رحلتك تبدأ من هنا.',
|
||||
style: const TextStyle(
|
||||
color: _loginInk,
|
||||
fontSize: 30,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -.7),
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
_codeSent
|
||||
? 'أدخل الرمز المكوّن من ٦ أرقام الذي وصلك برسالة.'
|
||||
: 'استخدم رقم هاتفك لحفظ تقدمك ومزامنة نشاطك بأمان.',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF778078), fontSize: 14, height: 1.8),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (!_codeSent) ...[
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false, label: Text('تسجيل الدخول')),
|
||||
ButtonSegment(value: true, label: Text('حساب جديد')),
|
||||
],
|
||||
selected: {_registration},
|
||||
onSelectionChanged: _busy
|
||||
? null
|
||||
: (value) =>
|
||||
setState(() => _registration = value.first),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (_registration) ...[
|
||||
_InputField(
|
||||
controller: _name,
|
||||
label: 'الاسم',
|
||||
hint: 'كيف نناديك؟',
|
||||
keyboardType: TextInputType.name),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_InputField(
|
||||
controller: _phone,
|
||||
label: 'رقم الهاتف',
|
||||
hint: '+9627XXXXXXXX',
|
||||
keyboardType: TextInputType.phone,
|
||||
direction: TextDirection.ltr),
|
||||
] else ...[
|
||||
_InputField(
|
||||
controller: _phone,
|
||||
label: 'رقم الهاتف',
|
||||
hint: '+9627XXXXXXXX',
|
||||
keyboardType: TextInputType.phone,
|
||||
direction: TextDirection.ltr,
|
||||
enabled: false),
|
||||
const SizedBox(height: 12),
|
||||
_InputField(
|
||||
controller: _code,
|
||||
label: 'رمز التحقق',
|
||||
hint: '000000',
|
||||
keyboardType: TextInputType.number,
|
||||
direction: TextDirection.ltr,
|
||||
maxLength: 6),
|
||||
const SizedBox(height: 9),
|
||||
TextButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => setState(() {
|
||||
_codeSent = false;
|
||||
_code.clear();
|
||||
}),
|
||||
child: const Text('تغيير الرقم أو طلب رمز جديد')),
|
||||
],
|
||||
const SizedBox(height: 13),
|
||||
FilledButton(
|
||||
onPressed:
|
||||
_busy ? null : (_codeSent ? _verifyCode : _requestCode),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _loginGreen,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15))),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
width: 21,
|
||||
height: 21,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: Text(_codeSent ? 'تأكيد الرمز' : 'إرسال رمز التحقق',
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!auth.isApiConfigured)
|
||||
const Text(
|
||||
'نسخة التطوير تحتاج عنوان API آمنًا عبر API_BASE_URL قبل تسجيل الدخول.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF8A6254),
|
||||
fontSize: 11,
|
||||
height: 1.7)),
|
||||
const SizedBox(height: 22),
|
||||
const Text(
|
||||
'نستخدم رقمك للتحقق من الحساب فقط. لا نقرأ بيانات البصمة أو الوجه؛ المصادقة الحيوية تبقى داخل نظام جهازك.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF899189), fontSize: 10, height: 1.8)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InputField extends StatelessWidget {
|
||||
const _InputField(
|
||||
{required this.controller,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.keyboardType,
|
||||
this.direction,
|
||||
this.enabled = true,
|
||||
this.maxLength});
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextInputType keyboardType;
|
||||
final TextDirection? direction;
|
||||
final bool enabled;
|
||||
final int? maxLength;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
textDirection: direction,
|
||||
maxLength: maxLength,
|
||||
style: const TextStyle(color: _loginInk, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
borderSide: const BorderSide(color: Color(0xFFE6EBE3))),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
borderSide: const BorderSide(color: _loginGreen, width: 1.4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import '../utils/geo_utils.dart';
|
||||
/// خدمة التحليل الذكي (Smart Analytics Service)
|
||||
/// مسؤولة عن تقسيم المسار إلى أجزاء، كشف التوقفات، وتحليل أنماط الأداء.
|
||||
class AnalyticsService extends GetxService {
|
||||
|
||||
/// تحليل المسار وتقسيمه إلى أجزاء (Segments)
|
||||
/// يعتمد على تغيرات السرعة، الارتفاع، أو الاتجاه.
|
||||
List<WorkoutSegment> analyzeSegments(List<GpsCoordinate> coords, WorkoutType type) {
|
||||
List<WorkoutSegment> analyzeSegments(
|
||||
List<GpsCoordinate> coords, WorkoutType type) {
|
||||
if (coords.length < 5) return [];
|
||||
|
||||
List<WorkoutSegment> segments = [];
|
||||
@@ -20,35 +20,38 @@ class AnalyticsService extends GetxService {
|
||||
// 1. تحديد مرحلة الإحماء (Warm-up)
|
||||
// نعتبر أول 10% من النقاط أو أول 500 متر إحماء إذا كانت السرعة متدرجة
|
||||
int warmupEnd = (coords.length * 0.1).toInt().clamp(5, 50);
|
||||
segments.add(_createSegment(coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup));
|
||||
segments.add(_createSegment(
|
||||
coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup));
|
||||
startIdx = warmupEnd;
|
||||
|
||||
// 2. تحليل النقاط الوسطى لكشف التوقفات أو التسارعات
|
||||
for (int i = startIdx + 1; i < coords.length - 5; i++) {
|
||||
final current = coords[i];
|
||||
final prev = coords[i - 1];
|
||||
|
||||
// كشف التوقف (Stop detection)
|
||||
// إذا كانت السرعة شبه صفرية لمسافة معينة
|
||||
if (current.speed != null && current.speed! < 0.3) {
|
||||
int stopEnd = i;
|
||||
while (stopEnd < coords.length - 1 &&
|
||||
(coords[stopEnd].speed ?? 0) < 0.3 &&
|
||||
stopEnd - i < 20) { // توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة
|
||||
while (stopEnd < coords.length - 1 &&
|
||||
(coords[stopEnd].speed ?? 0) < 0.3 &&
|
||||
stopEnd - i < 20) {
|
||||
// توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة
|
||||
stopEnd++;
|
||||
}
|
||||
|
||||
if (stopEnd - i > 3) { // توقف حقيقي لأكثر من 10 ثواني تقريباً
|
||||
|
||||
if (stopEnd - i > 3) {
|
||||
// توقف حقيقي لأكثر من 10 ثواني تقريباً
|
||||
// إغلاق المقطع السابق إذا كان هناك مسافة
|
||||
if (i > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, i, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(
|
||||
coords, startIdx, i, segmentCounter++, SegmentType.main));
|
||||
}
|
||||
segments.add(_createSegment(coords, i, stopEnd, segmentCounter++, SegmentType.stop));
|
||||
segments.add(_createSegment(
|
||||
coords, i, stopEnd, segmentCounter++, SegmentType.stop));
|
||||
i = stopEnd;
|
||||
startIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// كشف الميول (Elevation detection)
|
||||
// يمكن التوسع هنا لكشف uphill/downhill
|
||||
}
|
||||
@@ -56,36 +59,37 @@ class AnalyticsService extends GetxService {
|
||||
// 3. تحديد مرحلة التبريد (Cool-down)
|
||||
int cooldownStart = (coords.length * 0.9).toInt();
|
||||
if (cooldownStart > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, cooldownStart, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(coords, cooldownStart, coords.length - 1, segmentCounter++, SegmentType.cooldown));
|
||||
segments.add(_createSegment(
|
||||
coords, startIdx, cooldownStart, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(coords, cooldownStart, coords.length - 1,
|
||||
segmentCounter++, SegmentType.cooldown));
|
||||
} else {
|
||||
segments.add(_createSegment(coords, startIdx, coords.length - 1, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(coords, startIdx, coords.length - 1,
|
||||
segmentCounter++, SegmentType.main));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
WorkoutSegment _createSegment(List<GpsCoordinate> coords, int start, int end, int index, SegmentType type) {
|
||||
WorkoutSegment _createSegment(List<GpsCoordinate> coords, int start, int end,
|
||||
int index, SegmentType type) {
|
||||
double dist = 0;
|
||||
double maxSpd = 0;
|
||||
double sumSpd = 0;
|
||||
double elevChange = 0;
|
||||
|
||||
for (int i = start + 1; i <= end; i++) {
|
||||
dist += GeoUtils.haversineDistance(
|
||||
coords[i-1].latitude, coords[i-1].longitude,
|
||||
coords[i].latitude, coords[i].longitude
|
||||
);
|
||||
dist += GeoUtils.haversineDistance(coords[i - 1].latitude,
|
||||
coords[i - 1].longitude, coords[i].latitude, coords[i].longitude);
|
||||
double s = coords[i].speed ?? 0;
|
||||
if (s > maxSpd) maxSpd = s;
|
||||
sumSpd += s;
|
||||
|
||||
if (coords[i].altitude != null && coords[i-1].altitude != null) {
|
||||
elevChange += (coords[i].altitude! - coords[i-1].altitude!);
|
||||
|
||||
if (coords[i].altitude != null && coords[i - 1].altitude != null) {
|
||||
elevChange += (coords[i].altitude! - coords[i - 1].altitude!);
|
||||
}
|
||||
}
|
||||
|
||||
int duration = coords[end].timestamp.difference(coords[start].timestamp).inSeconds;
|
||||
int duration =
|
||||
coords[end].timestamp.difference(coords[start].timestamp).inSeconds;
|
||||
|
||||
return WorkoutSegment(
|
||||
segmentIndex: index,
|
||||
@@ -104,16 +108,18 @@ class AnalyticsService extends GetxService {
|
||||
/// مثال: "سرعتك في أول 2 كم تحسنت بنسبة 12%"
|
||||
String generateInsight(List<Workout> history) {
|
||||
if (history.length < 2) return "ابدأ تمرينك الأول للحصول على تحليلات!";
|
||||
|
||||
|
||||
// منطق بسيط للمقارنة بين آخر تمرينين
|
||||
final latest = history[0];
|
||||
final previous = history[1];
|
||||
|
||||
if (latest.avgSpeedMps > previous.avgSpeedMps) {
|
||||
double diff = ((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) * 100;
|
||||
|
||||
if (latest.avgSpeedMps > previous.avgSpeedMps && previous.avgSpeedMps > 0) {
|
||||
double diff =
|
||||
((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) *
|
||||
100;
|
||||
return "أداء رائع! سرعتك المتوسطة تحسنت بنسبة ${diff.toStringAsFixed(1)}% مقارنة بآخر مرة.";
|
||||
}
|
||||
|
||||
|
||||
return "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class AuthService {
|
||||
AuthService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
static const _apiOrigin = String.fromEnvironment('API_BASE_URL');
|
||||
static const _accessKey = 'sportpath_access_token';
|
||||
static const _refreshKey = 'sportpath_refresh_token';
|
||||
static const _deviceKey = 'sportpath_device_uuid';
|
||||
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
final http.Client _client;
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
String? _deviceUuid;
|
||||
String? _pendingChallengeId;
|
||||
String? _pendingPhone;
|
||||
bool? _pendingRegistration;
|
||||
Future<bool>? _refreshing;
|
||||
bool get isSignedIn => _refreshToken != null && _refreshToken!.isNotEmpty;
|
||||
bool get isApiConfigured => _apiOrigin.startsWith('https://');
|
||||
String get apiBase => '${_apiOrigin.replaceFirst(RegExp(r'/+$'), '')}/api/v1';
|
||||
|
||||
Future<AuthService> init() async {
|
||||
_accessToken = await _storage.read(key: _accessKey);
|
||||
_refreshToken = await _storage.read(key: _refreshKey);
|
||||
_deviceUuid = await _storage.read(key: _deviceKey);
|
||||
_deviceUuid ??= const Uuid().v4();
|
||||
await _storage.write(key: _deviceKey, value: _deviceUuid);
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<void> requestOtp(
|
||||
{required String phoneE164, required bool isRegistration}) async {
|
||||
_requireApi();
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/request-otp.php'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'phone_e164': phoneE164,
|
||||
'purpose': isRegistration ? 'register' : 'login',
|
||||
'device_uuid': _deviceUuid,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
_throwIfNotSuccessful(response);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_pendingChallengeId = body['challenge_id'] as String?;
|
||||
_pendingPhone = phoneE164;
|
||||
_pendingRegistration = isRegistration;
|
||||
if (_pendingChallengeId == null) {
|
||||
throw const AuthException('لم يصل معرّف التحقق من الخادم.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyOtp({
|
||||
required String phoneE164,
|
||||
required String code,
|
||||
required bool isRegistration,
|
||||
String? displayName,
|
||||
}) async {
|
||||
_requireApi();
|
||||
final challengeId = _pendingChallengeId;
|
||||
if (challengeId == null ||
|
||||
_pendingPhone != phoneE164 ||
|
||||
_pendingRegistration != isRegistration) {
|
||||
throw const AuthException('اطلب رمز تحقق جديدًا أولًا.');
|
||||
}
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/verify-otp.php'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'challenge_id': challengeId,
|
||||
'code': code,
|
||||
'display_name': displayName,
|
||||
'device_uuid': _deviceUuid,
|
||||
'platform':
|
||||
defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android',
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
_throwIfNotSuccessful(response);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = body['access_token'] as String?;
|
||||
_refreshToken = body['refresh_token'] as String?;
|
||||
if (_accessToken == null || _refreshToken == null) {
|
||||
throw const AuthException('ردّ الخادم لا يحتوي بيانات جلسة صالحة.');
|
||||
}
|
||||
await _storage.write(key: _accessKey, value: _accessToken);
|
||||
await _storage.write(key: _refreshKey, value: _refreshToken);
|
||||
_pendingChallengeId = null;
|
||||
_pendingPhone = null;
|
||||
_pendingRegistration = null;
|
||||
}
|
||||
|
||||
Future<http.Response> authenticatedPost(Uri uri, String body) async {
|
||||
_requireApi();
|
||||
_accessToken ??= await _storage.read(key: _accessKey);
|
||||
var token = _accessToken;
|
||||
if (token == null && !await _refreshSession()) {
|
||||
throw const AuthException('سجّل الدخول لمزامنة بياناتك.');
|
||||
}
|
||||
token = _accessToken;
|
||||
var response = await _client
|
||||
.post(uri, headers: _bearerHeaders(token!), body: body)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode == 401 && await _refreshSession()) {
|
||||
response = await _client
|
||||
.post(uri, headers: _bearerHeaders(_accessToken!), body: body)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<bool> _refreshSession() async {
|
||||
final activeRefresh = _refreshing;
|
||||
if (activeRefresh != null) return activeRefresh;
|
||||
final refreshFuture = _performRefresh();
|
||||
_refreshing = refreshFuture;
|
||||
try {
|
||||
return await refreshFuture;
|
||||
} finally {
|
||||
if (identical(_refreshing, refreshFuture)) _refreshing = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh() async {
|
||||
final refreshToken = _refreshToken ?? await _storage.read(key: _refreshKey);
|
||||
if (refreshToken == null || refreshToken.isEmpty || !isApiConfigured) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/refresh.php'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'refresh_token': refreshToken}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await clearSession();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = body['access_token'] as String?;
|
||||
_refreshToken = body['refresh_token'] as String?;
|
||||
if (_accessToken == null || _refreshToken == null) {
|
||||
await clearSession();
|
||||
return false;
|
||||
}
|
||||
await _storage.write(key: _accessKey, value: _accessToken);
|
||||
await _storage.write(key: _refreshKey, value: _refreshToken);
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[AUTH] Refresh failed: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final token = _accessToken ?? await _storage.read(key: _accessKey);
|
||||
if (token != null && isApiConfigured) {
|
||||
try {
|
||||
var response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/logout.php'),
|
||||
headers: _bearerHeaders(token),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode == 401 && await _refreshSession()) {
|
||||
response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/logout.php'),
|
||||
headers: _bearerHeaders(_accessToken!),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('[AUTH] Remote sign out failed: $error');
|
||||
}
|
||||
}
|
||||
await clearSession();
|
||||
}
|
||||
|
||||
Future<void> clearSession() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
await _storage.delete(key: _accessKey);
|
||||
await _storage.delete(key: _refreshKey);
|
||||
}
|
||||
|
||||
Map<String, String> _bearerHeaders(String token) => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
void _requireApi() {
|
||||
if (!isApiConfigured) {
|
||||
throw const AuthException(
|
||||
'عنوان API غير مضبوط. أضف API_BASE_URL عند بناء التطبيق.');
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfNotSuccessful(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
||||
String message = 'تعذر إكمال الطلب (${response.statusCode}).';
|
||||
try {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
switch (body['error']) {
|
||||
case 'rate_limited':
|
||||
message = 'طلبات كثيرة. انتظر قليلًا ثم حاول مجددًا.';
|
||||
break;
|
||||
case 'otp_provider_not_configured':
|
||||
message = 'خدمة الرسائل لم تُفعّل على الخادم بعد.';
|
||||
break;
|
||||
case 'invalid_code':
|
||||
message = 'رمز التحقق غير صحيح.';
|
||||
break;
|
||||
case 'invalid_or_expired_challenge':
|
||||
message = 'انتهت صلاحية رمز التحقق. اطلب رمزًا جديدًا.';
|
||||
break;
|
||||
case 'verification_failed':
|
||||
message = 'تعذر التحقق من الحساب أو الرمز.';
|
||||
break;
|
||||
case 'account_inactive':
|
||||
message = 'الحساب غير نشط. تواصل مع الدعم.';
|
||||
break;
|
||||
case 'otp_delivery_failed':
|
||||
message = 'تعذر إرسال رمز التحقق. حاول لاحقًا.';
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
throw AuthException(message);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -245,7 +245,11 @@ class DatabaseService extends GetxService {
|
||||
}
|
||||
|
||||
/// Atomically persist a finished workout, its segments, and its sync outbox item.
|
||||
Future<void> saveCompletedWorkout(Workout workout, String payload) async {
|
||||
Future<void> saveCompletedWorkout(
|
||||
Workout workout,
|
||||
String payload, {
|
||||
bool queueForSync = true,
|
||||
}) async {
|
||||
await _db.transaction((txn) async {
|
||||
final saved = await txn.query(
|
||||
'workouts',
|
||||
@@ -286,6 +290,8 @@ class DatabaseService extends GetxService {
|
||||
await txn.insert('workout_segments', values);
|
||||
}
|
||||
|
||||
if (!queueForSync) return;
|
||||
|
||||
final queued = await txn.query(
|
||||
'sync_queue',
|
||||
columns: ['id', 'status'],
|
||||
@@ -438,10 +444,13 @@ class DatabaseService extends GetxService {
|
||||
}
|
||||
|
||||
/// Get all queued items
|
||||
Future<List<Map<String, dynamic>>> getPendingSyncs() async {
|
||||
Future<List<Map<String, dynamic>>> getPendingSyncs(
|
||||
{bool includeExhausted = false}) async {
|
||||
return await _db.query(
|
||||
'sync_queue',
|
||||
where: "status IN ('queued', 'failed') AND retry_count < 5",
|
||||
where: includeExhausted
|
||||
? "status IN ('queued', 'failed')"
|
||||
: "status IN ('queued', 'failed') AND retry_count < 5",
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,15 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/workout.dart';
|
||||
import '../utils/hmac_utility.dart';
|
||||
import 'auth_service.dart';
|
||||
import 'database_service.dart';
|
||||
|
||||
/// محرك المزامنة التلقائي
|
||||
/// يراقب الاتصال بالإنترنت ويرسل التمارين المحفوظة محلياً عند توفر الشبكة
|
||||
class SyncService extends GetxService {
|
||||
final DatabaseService _db = Get.find<DatabaseService>();
|
||||
final _storage = const FlutterSecureStorage();
|
||||
final AuthService _auth = Get.find<AuthService>();
|
||||
final _connectivity = Connectivity();
|
||||
|
||||
StreamSubscription? _connectivitySub;
|
||||
@@ -27,10 +25,6 @@ class SyncService extends GetxService {
|
||||
bool get isOnline => _isOnline.value;
|
||||
int get pendingCount => _pendingCount.value;
|
||||
|
||||
static const String _apiEndpointKey = 'api_endpoint';
|
||||
static const String _apiKeyKey = 'api_key';
|
||||
static const String _apiSecretKey = 'api_secret';
|
||||
|
||||
Future<SyncService> init() async {
|
||||
await _checkConnectivity();
|
||||
_startConnectivityMonitor();
|
||||
@@ -73,44 +67,20 @@ class SyncService extends GetxService {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Credentials Management ───────────────────────────────────
|
||||
|
||||
Future<void> saveCredentials({
|
||||
required String apiEndpoint,
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
}) async {
|
||||
await _storage.write(key: _apiEndpointKey, value: apiEndpoint);
|
||||
await _storage.write(key: _apiKeyKey, value: apiKey);
|
||||
await _storage.write(key: _apiSecretKey, value: apiSecret);
|
||||
}
|
||||
|
||||
Future<Map<String, String?>> getCredentials() async {
|
||||
return {
|
||||
'endpoint': await _storage.read(key: _apiEndpointKey),
|
||||
'apiKey': await _storage.read(key: _apiKeyKey),
|
||||
'apiSecret': await _storage.read(key: _apiSecretKey),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> hasCredentials() async {
|
||||
final creds = await getCredentials();
|
||||
return creds['apiKey'] != null &&
|
||||
creds['apiKey']!.isNotEmpty &&
|
||||
creds['apiSecret'] != null &&
|
||||
creds['apiSecret']!.isNotEmpty;
|
||||
}
|
||||
|
||||
// ─── Sync Operations ─────────────────────────────────────────
|
||||
|
||||
/// Queue a workout for sync
|
||||
Future<void> queueWorkout(Workout workout) async {
|
||||
final payload = jsonEncode(workout.toApiPayload());
|
||||
await _db.saveCompletedWorkout(workout, payload);
|
||||
final queueForSync = workout.coordinates.length >= 2 &&
|
||||
workout.routePolyline != null &&
|
||||
workout.routePolyline!.isNotEmpty;
|
||||
await _db.saveCompletedWorkout(workout, payload,
|
||||
queueForSync: queueForSync);
|
||||
await _updatePendingCount();
|
||||
|
||||
// Try immediate sync if online
|
||||
if (_isOnline.value) {
|
||||
if (queueForSync && _isOnline.value) {
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
}
|
||||
@@ -124,32 +94,29 @@ class SyncService extends GetxService {
|
||||
}
|
||||
|
||||
/// Process all pending sync items
|
||||
Future<void> syncPendingWorkouts() async {
|
||||
Future<void> syncPendingWorkouts({bool retryNow = false}) async {
|
||||
if (_isSyncing.value) return;
|
||||
_isSyncing.value = true;
|
||||
|
||||
try {
|
||||
final creds = await getCredentials();
|
||||
if (creds['endpoint'] == null || creds['apiKey'] == null ||
|
||||
creds['apiSecret'] == null) {
|
||||
debugPrint('[SYNC] No API credentials configured');
|
||||
if (!_auth.isSignedIn) {
|
||||
debugPrint('[SYNC] Sign in to sync saved workouts');
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingItems = await _db.getPendingSyncs();
|
||||
final pendingItems = await _db.getPendingSyncs(
|
||||
includeExhausted: retryNow,
|
||||
);
|
||||
if (pendingItems.isEmpty) return;
|
||||
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
|
||||
for (final item in pendingItems) {
|
||||
if (!_isRetryDue(item)) continue;
|
||||
if (!retryNow && !_isRetryDue(item)) continue;
|
||||
await _syncSingleItem(
|
||||
id: item['id'] as int,
|
||||
workoutId: item['workout_id'] as String,
|
||||
payload: item['payload'] as String,
|
||||
endpoint: creds['endpoint']!,
|
||||
apiKey: creds['apiKey']!,
|
||||
apiSecret: creds['apiSecret']!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,25 +133,13 @@ class SyncService extends GetxService {
|
||||
required int id,
|
||||
required String workoutId,
|
||||
required String payload,
|
||||
required String endpoint,
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
}) async {
|
||||
try {
|
||||
final headers = HmacUtility.buildHeaders(
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
payload: payload,
|
||||
final response = await _auth.authenticatedPost(
|
||||
Uri.parse('${_auth.apiBase}/workouts.php'),
|
||||
payload,
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$endpoint/api/v1/workouts'),
|
||||
headers: headers,
|
||||
body: payload,
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
await _db.updateSyncQueueItem(id, 'sent');
|
||||
await _db.updateSyncStatus(
|
||||
|
||||
Reference in New Issue
Block a user