This commit is contained in:
2026-06-15 16:42:28 +03:30
commit 068930c7bd
158 changed files with 5330 additions and 0 deletions
+17
View File
@@ -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';
}
+27
View File
@@ -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);
},
));
}
}
+81
View File
@@ -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();
}
}
+16
View File
@@ -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);
}
+69
View File
@@ -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),
),
),
),
);
}
}