Connect Flutter phone auth and Android platform

This commit is contained in:
Hamza-Ayed
2026-10-04 01:44:07 +03:00
parent 5b90da6b18
commit 98490a17e6
44 changed files with 1271 additions and 168 deletions
+6 -3
View File
@@ -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
View File
@@ -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 إلى حين تسجيل الدخول والاتصال. لا تحذف التطبيق أو بياناته أثناء المزامنة إن كان لديك سجل غير متزامن.
+11
View File
@@ -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
+45
View File
@@ -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>
+24
View File
@@ -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)
}
+6
View File
@@ -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
Vendored Executable
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+26
View File
@@ -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")
+16 -2
View File
@@ -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) {
+7 -2
View File
@@ -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(),
);
}
}
+19
View File
@@ -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(),
+287
View File
@@ -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)),
),
);
}
+38 -32
View File
@@ -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 "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
}
}
+251
View File
@@ -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;
}
+12 -3
View File
@@ -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',
);
}
+18 -63
View File
@@ -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(