feat(proj): init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/// User-facing product name on the home screen and in the UI.
|
||||
abstract final class AppBrand {
|
||||
static const name = 'Дожить до ЗП';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:please_pay_me/core/config/env_file.dart';
|
||||
|
||||
/// Runtime configuration.
|
||||
///
|
||||
/// Values are resolved in this order:
|
||||
/// 1. `--dart-define=PPM_*` / `--dart-define-from-file=.env` (CI and `run.ps1`)
|
||||
/// 2. key/value map parsed from `mobile/.env` (tests and explicit loaders)
|
||||
/// 3. production cabinet if nothing is set
|
||||
class AppConfig {
|
||||
static const productionOrigin = 'https://please-pay-me.ru';
|
||||
|
||||
const AppConfig({
|
||||
required this.apiBaseUrl,
|
||||
required this.webCabinetUrl,
|
||||
this.demoMode = false,
|
||||
});
|
||||
|
||||
factory AppConfig.fromEnvironment({Map<String, String> file = const {}}) {
|
||||
const definedApi = String.fromEnvironment('PPM_API_BASE_URL');
|
||||
const definedWeb = String.fromEnvironment('PPM_WEB_URL');
|
||||
const definedDemo = String.fromEnvironment('PPM_DEMO');
|
||||
|
||||
return AppConfig.fromMap({
|
||||
...file,
|
||||
if (definedApi.isNotEmpty) 'PPM_API_BASE_URL': definedApi,
|
||||
if (definedWeb.isNotEmpty) 'PPM_WEB_URL': definedWeb,
|
||||
if (definedDemo.isNotEmpty) 'PPM_DEMO': definedDemo,
|
||||
});
|
||||
}
|
||||
|
||||
factory AppConfig.fromMap(Map<String, String> values) {
|
||||
final apiBase = normalizeUrl(values['PPM_API_BASE_URL'] ?? '');
|
||||
final webUrl = normalizeUrl(values['PPM_WEB_URL'] ?? '');
|
||||
final demoForced = parseEnvFlag(values['PPM_DEMO']);
|
||||
final resolvedApi = apiBase.isEmpty ? productionOrigin : apiBase;
|
||||
final resolvedWeb = webUrl.isEmpty ? resolvedApi : webUrl;
|
||||
|
||||
return AppConfig(
|
||||
apiBaseUrl: resolvedApi,
|
||||
webCabinetUrl: resolvedWeb,
|
||||
demoMode: demoForced,
|
||||
);
|
||||
}
|
||||
|
||||
static const demo = AppConfig(apiBaseUrl: '', webCabinetUrl: '', demoMode: true);
|
||||
|
||||
final String apiBaseUrl;
|
||||
final String webCabinetUrl;
|
||||
final bool demoMode;
|
||||
|
||||
AppConfig copyWith({String? apiBaseUrl, String? webCabinetUrl, bool? demoMode}) {
|
||||
return AppConfig(
|
||||
apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl,
|
||||
webCabinetUrl: webCabinetUrl ?? this.webCabinetUrl,
|
||||
demoMode: demoMode ?? this.demoMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Minimal `.env` parser (KEY=VALUE, `#` comments, optional quotes).
|
||||
///
|
||||
/// Kept tiny and dependency-free so config loading is easy to test and does
|
||||
/// not pull `flutter_dotenv` into the production graph.
|
||||
Map<String, String> parseEnvFile(String source) {
|
||||
final values = <String, String>{};
|
||||
|
||||
for (final raw in source.split(RegExp(r'\r?\n'))) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty || line.startsWith('#')) continue;
|
||||
|
||||
final separator = line.indexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
|
||||
final key = line.substring(0, separator).trim();
|
||||
if (key.isEmpty) continue;
|
||||
|
||||
var value = line.substring(separator + 1).trim();
|
||||
if (value.length >= 2) {
|
||||
final quote = value[0];
|
||||
if ((quote == '"' || quote == "'") && value.endsWith(quote)) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
String normalizeUrl(String url) => url.trim().replaceAll(RegExp(r'/$'), '');
|
||||
|
||||
bool parseEnvFlag(String? raw, {bool fallback = false}) {
|
||||
if (raw == null || raw.trim().isEmpty) return fallback;
|
||||
return switch (raw.trim().toLowerCase()) {
|
||||
'1' || 'true' || 'yes' || 'on' => true,
|
||||
'0' || 'false' || 'no' || 'off' => false,
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'env_loader_stub.dart' if (dart.library.io) 'env_loader_io.dart' as impl;
|
||||
|
||||
Future<Map<String, String>> loadEnvFile() => impl.loadEnvFileImpl();
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:please_pay_me/core/config/env_file.dart';
|
||||
|
||||
/// Reads `mobile/.env` when the process cwd is the package or the repo root.
|
||||
/// Dart-defines from `run.ps1` still win in [AppConfig.fromEnvironment].
|
||||
Future<Map<String, String>> loadEnvFileImpl() async {
|
||||
for (final path in const ['.env', 'mobile/.env']) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
return parseEnvFile(await file.readAsString());
|
||||
}
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Future<Map<String, String>> loadEnvFileImpl() async => const {};
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// `1 234,50 ₽` — same shape as the web cabinet.
|
||||
String formatMoney(double amount, {String currency = 'RUB', bool compact = false}) {
|
||||
final symbol = switch (currency) {
|
||||
'RUB' => '₽',
|
||||
'USD' => r'$',
|
||||
'EUR' => '€',
|
||||
_ => currency,
|
||||
};
|
||||
|
||||
final formatter = compact
|
||||
? NumberFormat.decimalPattern('ru')
|
||||
: NumberFormat('#,##0.00', 'ru');
|
||||
final value = compact ? amount.round() : amount;
|
||||
|
||||
return '${formatter.format(value)} $symbol'.replaceAll('\u00A0', ' ');
|
||||
}
|
||||
|
||||
String formatSignedMoney(double amount, {String currency = 'RUB'}) {
|
||||
final sign = amount < 0 ? '+' : '−';
|
||||
return '$sign${formatMoney(amount.abs(), currency: currency)}';
|
||||
}
|
||||
|
||||
String formatDay(DateTime date) => DateFormat('d MMMM', 'ru').format(date);
|
||||
|
||||
String formatShortDate(DateTime date) => DateFormat('dd.MM.yyyy').format(date);
|
||||
|
||||
String formatWeekday(DateTime date) => DateFormat('EEEE', 'ru').format(date);
|
||||
|
||||
/// `Сегодня` / `Вчера` / `12 сентября` — headers of the journal.
|
||||
String formatRelativeDay(DateTime date, {DateTime? now}) {
|
||||
final today = _dayOf(now ?? DateTime.now());
|
||||
final day = _dayOf(date);
|
||||
final diff = today.difference(day).inDays;
|
||||
|
||||
return switch (diff) {
|
||||
0 => 'Сегодня',
|
||||
1 => 'Вчера',
|
||||
_ => formatDay(day),
|
||||
};
|
||||
}
|
||||
|
||||
/// `осталось 5 дней` — Russian plural rules.
|
||||
String formatDaysLeft(int days) {
|
||||
if (days <= 0) return 'период завершён';
|
||||
return 'осталось ${plural(days, 'день', 'дня', 'дней')}';
|
||||
}
|
||||
|
||||
String plural(int count, String one, String few, String many) {
|
||||
final mod100 = count % 100;
|
||||
final mod10 = count % 10;
|
||||
|
||||
if (mod100 >= 11 && mod100 <= 14) return '$count $many';
|
||||
if (mod10 == 1) return '$count $one';
|
||||
if (mod10 >= 2 && mod10 <= 4) return '$count $few';
|
||||
return '$count $many';
|
||||
}
|
||||
|
||||
DateTime _dayOf(DateTime value) => DateTime(value.year, value.month, value.day);
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/config/app_config.dart';
|
||||
import 'package:please_pay_me/core/config/env_file.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
abstract final class LegalLinks {
|
||||
static const offer = '/legal/offer';
|
||||
static const privacy = '/legal/privacy';
|
||||
static const consent = '/legal/consent';
|
||||
static const cookies = '/legal/cookies';
|
||||
|
||||
static Uri resolve(String cabinetUrl, String path) {
|
||||
final base = cabinetUrl.isEmpty ? AppConfig.productionOrigin : cabinetUrl;
|
||||
return Uri.parse('${normalizeUrl(base)}$path');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openLegalDocument(BuildContext context, Uri uri) async {
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened && context.mounted) {
|
||||
await showAppToast(context, message: 'Не удалось открыть документ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Minimal async state container so screens can pattern-match over
|
||||
/// loading / data / error instead of juggling three nullable fields.
|
||||
sealed class AsyncValue<T> {
|
||||
const AsyncValue();
|
||||
|
||||
const factory AsyncValue.loading() = AsyncLoading<T>;
|
||||
const factory AsyncValue.data(T value) = AsyncData<T>;
|
||||
const factory AsyncValue.error(String message) = AsyncError<T>;
|
||||
|
||||
T? get valueOrNull => this is AsyncData<T> ? (this as AsyncData<T>).value : null;
|
||||
|
||||
bool get isLoading => this is AsyncLoading<T>;
|
||||
|
||||
R map<R>({
|
||||
required R Function() loading,
|
||||
required R Function(T value) data,
|
||||
required R Function(String message) error,
|
||||
}) {
|
||||
return switch (this) {
|
||||
AsyncLoading<T>() => loading(),
|
||||
AsyncData<T>(value: final v) => data(v),
|
||||
AsyncError<T>(message: final m) => error(m),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final class AsyncLoading<T> extends AsyncValue<T> {
|
||||
const AsyncLoading();
|
||||
}
|
||||
|
||||
final class AsyncData<T> extends AsyncValue<T> {
|
||||
const AsyncData(this.value);
|
||||
|
||||
final T value;
|
||||
}
|
||||
|
||||
final class AsyncError<T> extends AsyncValue<T> {
|
||||
const AsyncError(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persisted session: JWT, API address and the cached user profile.
|
||||
abstract interface class SessionStorage {
|
||||
Future<Map<String, String?>> readAll();
|
||||
|
||||
Future<void> write({
|
||||
required String token,
|
||||
required String baseUrl,
|
||||
required String user,
|
||||
});
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class PrefsSessionStorage implements SessionStorage {
|
||||
const PrefsSessionStorage();
|
||||
|
||||
static const _tokenKey = 'ppm_token';
|
||||
static const _baseUrlKey = 'ppm_base_url';
|
||||
static const _userKey = 'ppm_user';
|
||||
|
||||
@override
|
||||
Future<Map<String, String?>> readAll() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return {
|
||||
'token': prefs.getString(_tokenKey),
|
||||
'baseUrl': prefs.getString(_baseUrlKey),
|
||||
'user': prefs.getString(_userKey),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String token,
|
||||
required String baseUrl,
|
||||
required String user,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, token);
|
||||
await prefs.setString(_baseUrlKey, baseUrl);
|
||||
await prefs.setString(_userKey, user);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_baseUrlKey);
|
||||
await prefs.remove(_userKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Used by tests and previews — no platform channels involved.
|
||||
class InMemorySessionStorage implements SessionStorage {
|
||||
InMemorySessionStorage([Map<String, String?>? initial])
|
||||
: _values = {...?initial};
|
||||
|
||||
final Map<String, String?> _values;
|
||||
|
||||
@override
|
||||
Future<Map<String, String?>> readAll() async => Map.of(_values);
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String token,
|
||||
required String baseUrl,
|
||||
required String user,
|
||||
}) async {
|
||||
_values
|
||||
..['token'] = token
|
||||
..['baseUrl'] = baseUrl
|
||||
..['user'] = user;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _values.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user