Connect Flutter phone auth and Android platform
This commit is contained in:
@@ -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