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
+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(