init
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/// تنظیمات سراسری اپ.
|
||||
class AppConfig {
|
||||
/// آدرس پایهی سرور.
|
||||
/// - موبایل واقعی روی همان Wi-Fi: IP محلی مک (پیشفرض فعلی).
|
||||
/// - امولاتور اندروید: `--dart-define=BASE_URL=http://10.0.2.2:8080`
|
||||
/// - وب / شبیهساز iOS: `--dart-define=BASE_URL=http://localhost:8080`
|
||||
/// - پروداکشن: `--dart-define=BASE_URL=https://api.hakemsho.ir`
|
||||
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیشفرض را عوض کنید.
|
||||
static const String baseUrl =
|
||||
String.fromEnvironment('BASE_URL', defaultValue: 'http://192.168.100.28:8080');
|
||||
|
||||
static String get apiUrl => '$baseUrl/api';
|
||||
|
||||
/// آدرس WebSocket بازی بههمراه توکن احراز هویت.
|
||||
static String wsUrl(String token) =>
|
||||
'${baseUrl.replaceFirst('http', 'ws')}/ws?token=$token';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
/// کلاینت HTTP با تزریق خودکار توکن Bearer.
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
|
||||
ApiClient(TokenStorage storage)
|
||||
: dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {'Accept': 'application/json'},
|
||||
)) {
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final token = await storage.read();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../config.dart';
|
||||
|
||||
enum WsStatus { connecting, connected, disconnected }
|
||||
|
||||
/// کلاینت WebSocket بازی: اتصال با توکن، ارسال/دریافت JSON و اتصال مجدد خودکار.
|
||||
class WsClient {
|
||||
final String token;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
Timer? _reconnectTimer;
|
||||
bool _disposed = false;
|
||||
int _attempt = 0;
|
||||
|
||||
final _messages = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _status = StreamController<WsStatus>.broadcast();
|
||||
|
||||
WsClient(this.token);
|
||||
|
||||
/// پیامهای دیکدشدهی سرور.
|
||||
Stream<Map<String, dynamic>> get messages => _messages.stream;
|
||||
|
||||
/// وضعیت اتصال (برای نمایش «در حال اتصال مجدد»).
|
||||
Stream<WsStatus> get status => _status.stream;
|
||||
|
||||
void connect() {
|
||||
if (_disposed) return;
|
||||
_status.add(WsStatus.connecting);
|
||||
try {
|
||||
final ch = WebSocketChannel.connect(Uri.parse(AppConfig.wsUrl(token)));
|
||||
_channel = ch;
|
||||
_sub = ch.stream.listen(
|
||||
_onData,
|
||||
onError: (_) => _scheduleReconnect(),
|
||||
onDone: _scheduleReconnect,
|
||||
cancelOnError: true,
|
||||
);
|
||||
_attempt = 0;
|
||||
_status.add(WsStatus.connected);
|
||||
} catch (_) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _onData(dynamic raw) {
|
||||
try {
|
||||
final msg = jsonDecode(raw as String);
|
||||
if (msg is Map<String, dynamic>) _messages.add(msg);
|
||||
} catch (_) {/* پیام نامعتبر نادیده گرفته میشود */}
|
||||
}
|
||||
|
||||
void send(Map<String, dynamic> msg) {
|
||||
_channel?.sink.add(jsonEncode(msg));
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_disposed) return;
|
||||
_status.add(WsStatus.disconnected);
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_channel = null;
|
||||
_reconnectTimer?.cancel();
|
||||
// backoff تا حداکثر ۵ ثانیه؛ سرور با همان توکن بازیکن را به میز برمیگرداند.
|
||||
final delayMs = (500 * (1 << _attempt)).clamp(500, 5000);
|
||||
_attempt = (_attempt + 1).clamp(0, 4);
|
||||
_reconnectTimer = Timer(Duration(milliseconds: delayMs), connect);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_sub?.cancel();
|
||||
_channel?.sink.close();
|
||||
_messages.close();
|
||||
_status.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// ذخیرهی امن توکن JWT.
|
||||
class TokenStorage {
|
||||
static const _key = 'jwt_token';
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
TokenStorage([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
Future<String?> read() => _storage.read(key: _key);
|
||||
|
||||
Future<void> write(String token) => _storage.write(key: _key, value: token);
|
||||
|
||||
Future<void> clear() => _storage.delete(key: _key);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// تم بصری حکمشو (الهامگرفته از اپ مرجع: قرمز تیره و طلایی).
|
||||
class AppColors {
|
||||
static const bg = Color(0xFF3A0A12);
|
||||
static const bgDark = Color(0xFF240108);
|
||||
static const panel = Color(0xFF5A0E1A);
|
||||
static const gold = Color(0xFFE9B949);
|
||||
static const goldDark = Color(0xFFB8860B);
|
||||
static const accent = Color(0xFFB81D2A);
|
||||
static const green = Color(0xFF2E7D32);
|
||||
static const text = Color(0xFFF5E9D0);
|
||||
}
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData build() {
|
||||
const fontFamily = 'Dana';
|
||||
final base = ThemeData.dark(useMaterial3: true);
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.bg,
|
||||
primaryColor: AppColors.accent,
|
||||
colorScheme: base.colorScheme.copyWith(
|
||||
primary: AppColors.gold,
|
||||
secondary: AppColors.accent,
|
||||
surface: AppColors.panel,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
fontFamily: fontFamily,
|
||||
bodyColor: AppColors.text,
|
||||
displayColor: AppColors.text,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.bgDark,
|
||||
foregroundColor: AppColors.gold,
|
||||
centerTitle: true,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.bgDark.withValues(alpha: 0.6),
|
||||
hintStyle: const TextStyle(color: Colors.white38),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.goldDark),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.goldDark),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.gold, width: 2),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.text,
|
||||
minimumSize: const Size.fromHeight(54),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: fontFamily, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: AppColors.gold, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user