feat(proj): init
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/branding/app_brand.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/features/auth/session_controller.dart';
|
||||
import 'package:please_pay_me/features/auth/telegram_login.dart';
|
||||
import 'package:please_pay_me/features/auth/yandex_login.dart';
|
||||
import 'package:please_pay_me/features/legal/legal_consent.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Opens the cabinet and returns the harvested JWT.
|
||||
typedef TelegramLoginLauncher = Future<String?> Function(
|
||||
BuildContext context,
|
||||
String cabinetUrl,
|
||||
);
|
||||
|
||||
/// Opens Yandex OAuth and returns the JWT issued by `/api/auth/yandex`.
|
||||
typedef YandexLoginLauncher = Future<String?> Function(
|
||||
BuildContext context, {
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
});
|
||||
|
||||
/// Sign-in screen.
|
||||
///
|
||||
/// Cabinet / API origin comes from `.env` (`PPM_WEB_URL`, `PPM_API_BASE_URL`).
|
||||
/// Telegram: cabinet WebView + JWT channel. Yandex: OAuth code in a WebView,
|
||||
/// exchanged on the API so the client secret never leaves the server.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({
|
||||
super.key,
|
||||
this.launchTelegramLogin,
|
||||
this.launchYandexLogin,
|
||||
});
|
||||
|
||||
/// Injection point for tests and Widgetbook, where no WebView exists.
|
||||
final TelegramLoginLauncher? launchTelegramLogin;
|
||||
final YandexLoginLauncher? launchYandexLogin;
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _tokenController = TextEditingController();
|
||||
|
||||
bool _busy = false;
|
||||
late bool _tokenMode = !_webLoginAvailable;
|
||||
YandexAuthProvider? _yandex;
|
||||
String? _providersError;
|
||||
LegalAcceptance _legal = const LegalAcceptance();
|
||||
|
||||
bool get _webLoginAvailable =>
|
||||
widget.launchTelegramLogin != null || isTelegramWebLoginSupported;
|
||||
|
||||
bool get _yandexLoginAvailable =>
|
||||
widget.launchYandexLogin != null || isYandexWebLoginSupported;
|
||||
|
||||
String _cabinetUrl(SessionController session) {
|
||||
if (session.webCabinetUrl.isNotEmpty) return session.webCabinetUrl;
|
||||
return session.baseUrl;
|
||||
}
|
||||
|
||||
String _apiBase(SessionController session) {
|
||||
return resolveApiBaseUrl(
|
||||
cabinetUrl: _cabinetUrl(session),
|
||||
configuredApiBaseUrl: session.configuredApiBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProviders();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProviders() async {
|
||||
final session = context.read<SessionController>();
|
||||
final apiBase = _apiBase(session);
|
||||
if (apiBase.isEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_providersError = 'Не задан PPM_API_BASE_URL в .env';
|
||||
});
|
||||
} else {
|
||||
_providersError = 'Не задан PPM_API_BASE_URL в .env';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final providers = await fetchAuthProviders(apiBase, httpClient: session.httpClient);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_yandex = providers.yandex;
|
||||
_providersError = providers.yandex?.usable == true
|
||||
? null
|
||||
: 'API не включил Яндекс (нет YANDEX_CLIENT_ID/SECRET на сервере)';
|
||||
});
|
||||
} on ApiException catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_yandex = null;
|
||||
_providersError = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_yandex = null;
|
||||
_providersError = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureCabinetUrl(SessionController session) async {
|
||||
if (_cabinetUrl(session).isNotEmpty) return true;
|
||||
await showAppToast(
|
||||
context,
|
||||
message: 'Задайте PPM_WEB_URL в mobile/.env',
|
||||
icon: CupertinoIcons.exclamationmark_circle_fill,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s7, bottom: AppSpacing.s6),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppIcon(
|
||||
CupertinoIcons.money_rubl_circle_fill,
|
||||
size: 48,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
SizedBox(height: AppSpacing.s3),
|
||||
AppText.largeTitle(AppBrand.name),
|
||||
SizedBox(height: AppSpacing.s1),
|
||||
AppText.subhead(
|
||||
'Бюджет от зарплаты до зарплаты. Войдите через Яндекс.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LegalConsentBlock(
|
||||
value: _legal,
|
||||
cabinetUrl: _cabinetUrl(session),
|
||||
onChanged: (next) => setState(() => _legal = next),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
if (_tokenMode) ...[
|
||||
AppTextField(
|
||||
label: 'Токен доступа',
|
||||
placeholder: 'eyJhbGciOi…',
|
||||
controller: _tokenController,
|
||||
enabled: !_busy,
|
||||
errorText: session.lastError,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppButton(
|
||||
label: 'Войти по токену',
|
||||
loading: _busy,
|
||||
onPressed: _legal.accepted ? _signInWithToken : null,
|
||||
),
|
||||
] else ...[
|
||||
if (session.lastError != null) ...[
|
||||
AppText.footnote(session.lastError!, color: AppColors.systemRed),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
AppButton(
|
||||
label: 'Войти через Яндекс',
|
||||
style: AppButtonStyle.gray,
|
||||
loading: _busy,
|
||||
onPressed: _legal.accepted ? _signInWithYandex : null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
const AppListSection(
|
||||
header: 'Что умеет',
|
||||
footer: 'Один кабинет на телефоне и в браузере — бюджет не разъедется.',
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: AppIcon(CupertinoIcons.calendar, color: AppColors.accent),
|
||||
title: 'Бюджет от зарплаты до зарплаты',
|
||||
subtitle: 'Остаток и дневной лимит на каждый день периода',
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
leading: AppIcon(CupertinoIcons.money_rubl, color: AppColors.accent),
|
||||
title: 'Траты в один тап',
|
||||
subtitle: 'Журнал по дням, несколько конвертов параллельно',
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
leading: AppIcon(CupertinoIcons.briefcase, color: AppColors.accent),
|
||||
title: 'График выплат',
|
||||
subtitle: 'Оклад, дни зарплаты и правило выходных',
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _signInWithTelegram() async {
|
||||
final session = context.read<SessionController>();
|
||||
if (!await _ensureCabinetUrl(session)) return;
|
||||
|
||||
final cabinetUrl = _cabinetUrl(session);
|
||||
final launcher = widget.launchTelegramLogin ??
|
||||
(ctx, url) => showTelegramLogin(context: ctx, cabinetUrl: url);
|
||||
|
||||
setState(() => _busy = true);
|
||||
final token = await launcher(context, cabinetUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await session.signInWithToken(baseUrl: _apiBase(session), token: token);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _signInWithYandex() async {
|
||||
final session = context.read<SessionController>();
|
||||
if (!await _ensureCabinetUrl(session)) return;
|
||||
|
||||
if (_yandex == null || !(_yandex?.usable ?? false)) {
|
||||
setState(() => _busy = true);
|
||||
await _loadProviders();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
final provider = _yandex;
|
||||
if (provider == null || !provider.usable) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: _providersError ?? 'Вход через Яндекс недоступен',
|
||||
icon: CupertinoIcons.exclamationmark_circle_fill,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_yandexLoginAvailable) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: 'Яндекс в приложении работает на телефоне. На Windows откройте кабинет в браузере.',
|
||||
icon: CupertinoIcons.device_phone_portrait,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final cabinetUrl = _cabinetUrl(session);
|
||||
final apiBase = _apiBase(session);
|
||||
final redirectUri = cabinetYandexRedirectUri(
|
||||
cabinetUrl,
|
||||
configured: provider.redirectUri,
|
||||
);
|
||||
|
||||
setState(() => _busy = true);
|
||||
|
||||
final launcher = widget.launchYandexLogin;
|
||||
final token = launcher != null
|
||||
? await launcher(context, clientId: provider.clientId, redirectUri: redirectUri)
|
||||
: await showYandexLogin(
|
||||
context: context,
|
||||
clientId: provider.clientId,
|
||||
redirectUri: redirectUri,
|
||||
exchangeCode: (code) => exchangeYandexCode(
|
||||
apiBaseUrl: apiBase,
|
||||
code: code,
|
||||
redirectUri: redirectUri,
|
||||
httpClient: session.httpClient,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await session.signInWithToken(baseUrl: apiBase, token: token);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _signInWithToken() async {
|
||||
final session = context.read<SessionController>();
|
||||
if (!await _ensureCabinetUrl(session)) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
await session.signInWithToken(
|
||||
baseUrl: _apiBase(session),
|
||||
token: _tokenController.text,
|
||||
);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:please_pay_me/core/config/app_config.dart';
|
||||
import 'package:please_pay_me/core/storage/session_storage.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/data/demo/demo_backend.dart';
|
||||
import 'package:please_pay_me/data/models/auth_user.dart';
|
||||
import 'package:please_pay_me/data/repositories/api_repositories.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
enum SessionStatus { restoring, signedOut, signedIn }
|
||||
|
||||
/// Owns authentication and hands out repositories bound to the current
|
||||
/// session, so the rest of the app never sees tokens or base URLs.
|
||||
class SessionController extends ChangeNotifier {
|
||||
SessionController({
|
||||
required AppConfig config,
|
||||
required SessionStorage storage,
|
||||
http.Client? httpClient,
|
||||
DemoBackend? demoBackend,
|
||||
}) : _config = config,
|
||||
_storage = storage,
|
||||
_httpClient = httpClient,
|
||||
_demo = demoBackend ?? DemoBackend();
|
||||
|
||||
final SessionStorage _storage;
|
||||
final http.Client? _httpClient;
|
||||
final DemoBackend _demo;
|
||||
|
||||
AppConfig _config;
|
||||
SessionStatus _status = SessionStatus.restoring;
|
||||
AuthUser? _user;
|
||||
String? _token;
|
||||
String _baseUrl = '';
|
||||
bool _isDemo = false;
|
||||
String? _lastError;
|
||||
|
||||
SessionStatus get status => _status;
|
||||
AuthUser? get user => _user;
|
||||
String get baseUrl => _baseUrl.isEmpty ? _config.apiBaseUrl : _baseUrl;
|
||||
String get webCabinetUrl => _config.webCabinetUrl;
|
||||
|
||||
/// API address baked in at build time; empty when it must be derived from
|
||||
/// the cabinet URL the user typed.
|
||||
String get configuredApiBaseUrl => _config.apiBaseUrl;
|
||||
bool get isDemo => _isDemo;
|
||||
String? get lastError => _lastError;
|
||||
http.Client? get httpClient => _httpClient;
|
||||
|
||||
/// Changes whenever the backing data source changes, so feature controllers
|
||||
/// can be rebuilt from scratch on login / logout.
|
||||
String get sessionKey => '${_isDemo ? 'demo' : baseUrl}:${_user?.userId ?? 0}';
|
||||
|
||||
BudgetRepository get budgets => _repositories.budgets;
|
||||
ExpenseRepository get expenses => _repositories.expenses;
|
||||
JobRepository get jobs => _repositories.jobs;
|
||||
UserRepository get users => _repositories.users;
|
||||
|
||||
_Repositories get _repositories {
|
||||
if (_isDemo || _token == null) return _demoRepositories;
|
||||
return _apiRepositories ??= _buildApiRepositories();
|
||||
}
|
||||
|
||||
_Repositories? _apiRepositories;
|
||||
|
||||
late final _Repositories _demoRepositories = _Repositories(
|
||||
budgets: _demo.budgets,
|
||||
expenses: _demo.expenses,
|
||||
jobs: _demo.jobs,
|
||||
users: _demo.users,
|
||||
);
|
||||
|
||||
_Repositories _buildApiRepositories() {
|
||||
final client = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
tokenProvider: () => _token,
|
||||
httpClient: _httpClient,
|
||||
onUnauthorized: signOut,
|
||||
);
|
||||
return _Repositories(
|
||||
budgets: ApiBudgetRepository(client),
|
||||
expenses: ApiExpenseRepository(client),
|
||||
jobs: ApiJobRepository(client),
|
||||
users: ApiUserRepository(client),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> restore() async {
|
||||
final stored = await _storage.readAll();
|
||||
final token = stored['token'];
|
||||
final baseUrl = stored['baseUrl'];
|
||||
|
||||
if (token != null && token.isNotEmpty && baseUrl != null && baseUrl.isNotEmpty) {
|
||||
_token = token;
|
||||
_baseUrl = baseUrl;
|
||||
_isDemo = false;
|
||||
_apiRepositories = null;
|
||||
_user = AuthUser.tryDecode(stored['user']);
|
||||
_status = SessionStatus.signedIn;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_config.demoMode && _config.apiBaseUrl.isEmpty) {
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Signs in with a JWT issued by Telegram or Yandex (`POST /api/auth/*`).
|
||||
Future<bool> signInWithToken({required String baseUrl, required String token}) async {
|
||||
final normalizedUrl = baseUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final normalizedToken = token.trim();
|
||||
|
||||
if (normalizedUrl.isEmpty || normalizedToken.isEmpty) {
|
||||
_lastError = 'Укажите адрес кабинета и токен';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastError = null;
|
||||
_baseUrl = normalizedUrl;
|
||||
_token = normalizedToken;
|
||||
_isDemo = false;
|
||||
_apiRepositories = null;
|
||||
|
||||
try {
|
||||
final user = await _repositories.users.me();
|
||||
_user = user;
|
||||
_status = SessionStatus.signedIn;
|
||||
_config = _config.copyWith(apiBaseUrl: normalizedUrl, demoMode: false);
|
||||
await _storage.write(
|
||||
token: normalizedToken,
|
||||
baseUrl: normalizedUrl,
|
||||
user: user.encode(),
|
||||
);
|
||||
notifyListeners();
|
||||
return true;
|
||||
} on ApiException catch (error) {
|
||||
_token = null;
|
||||
_apiRepositories = null;
|
||||
_lastError = error.message;
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the app against [DemoBackend] — no server required.
|
||||
void startDemo() {
|
||||
_isDemo = true;
|
||||
_token = null;
|
||||
_user = DemoBackend.user;
|
||||
_lastError = null;
|
||||
_status = SessionStatus.signedIn;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _storage.clear();
|
||||
_token = null;
|
||||
_user = null;
|
||||
_isDemo = false;
|
||||
_apiRepositories = null;
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _Repositories {
|
||||
const _Repositories({
|
||||
required this.budgets,
|
||||
required this.expenses,
|
||||
required this.jobs,
|
||||
required this.users,
|
||||
});
|
||||
|
||||
final BudgetRepository budgets;
|
||||
final ExpenseRepository expenses;
|
||||
final JobRepository jobs;
|
||||
final UserRepository users;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
/// Name of the JavaScript channel injected into the cabinet page.
|
||||
/// Must stay in sync with `web/src/auth/telegramRedirect.ts`.
|
||||
const telegramAuthChannel = 'PpmAuth';
|
||||
|
||||
/// Chrome-like UA without the `; wv` token Android WebView inserts.
|
||||
/// Telegram's widget rejects the default WebView user-agent on some devices.
|
||||
const telegramWebViewUserAgent =
|
||||
'Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
/// Runs inside the cabinet and hands the JWT back to Flutter.
|
||||
///
|
||||
/// Two paths, both without backend changes:
|
||||
/// 1. The web app posts to `PpmAuth` from `setSession` right after login.
|
||||
/// 2. This script hooks `localStorage.setItem` and polls, so an already-open
|
||||
/// session (or an older cabinet build) still works.
|
||||
const telegramTokenProbeJs = '''
|
||||
(function () {
|
||||
function ping() {
|
||||
try {
|
||||
var token = window.localStorage.getItem('ppm_session_jwt');
|
||||
if (token && window.$telegramAuthChannel) {
|
||||
$telegramAuthChannel.postMessage(JSON.stringify({ token: token }));
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!window.__ppmAuthHooked) {
|
||||
window.__ppmAuthHooked = true;
|
||||
try {
|
||||
var original = window.localStorage.setItem.bind(window.localStorage);
|
||||
window.localStorage.setItem = function (key, value) {
|
||||
original(key, value);
|
||||
if (key === 'ppm_session_jwt') ping();
|
||||
};
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
ping();
|
||||
})();
|
||||
''';
|
||||
|
||||
/// WebView login only exists on mobile; desktop and web fall back to the token
|
||||
/// form.
|
||||
bool get isTelegramWebLoginSupported {
|
||||
if (kIsWeb) return false;
|
||||
return defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
}
|
||||
|
||||
/// The cabinet and the API share an origin (nginx proxies `/api`), so the API
|
||||
/// address can be derived unless it was configured explicitly.
|
||||
String resolveApiBaseUrl({
|
||||
required String cabinetUrl,
|
||||
String configuredApiBaseUrl = '',
|
||||
}) {
|
||||
if (configuredApiBaseUrl.trim().isNotEmpty) {
|
||||
return configuredApiBaseUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
|
||||
return resolveCabinetOrigin(cabinetUrl);
|
||||
}
|
||||
|
||||
/// Origin of the cabinet URL — used both as the API base and as a sanity check.
|
||||
String resolveCabinetOrigin(String cabinetUrl) {
|
||||
final uri = resolveCabinetLoginUri(cabinetUrl);
|
||||
return uri.hasPort && !_isDefaultPort(uri)
|
||||
? '${uri.scheme}://${uri.host}:${uri.port}'
|
||||
: '${uri.scheme}://${uri.host}';
|
||||
}
|
||||
|
||||
/// Always open `/login` so the Telegram widget is on screen.
|
||||
Uri resolveCabinetLoginUri(String cabinetUrl) {
|
||||
var raw = cabinetUrl.trim();
|
||||
if (raw.isEmpty) return Uri.parse('https://localhost/login');
|
||||
|
||||
if (!raw.contains('://')) {
|
||||
raw = 'https://$raw';
|
||||
}
|
||||
|
||||
final uri = Uri.parse(raw);
|
||||
final path = uri.path;
|
||||
if (path.isEmpty || path == '/') {
|
||||
return uri.replace(path: '/login');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool _isDefaultPort(Uri uri) {
|
||||
return (uri.scheme == 'https' && uri.port == 443) ||
|
||||
(uri.scheme == 'http' && uri.port == 80);
|
||||
}
|
||||
|
||||
/// Extracts the JWT from the payload posted by [telegramTokenProbeJs].
|
||||
String? parseTelegramAuthMessage(String raw) {
|
||||
try {
|
||||
final token = asString(asMap(jsonDecode(raw))['token']);
|
||||
return token.isEmpty ? null : token;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool isExternalAuthScheme(Uri uri) {
|
||||
return uri.scheme == 'tg' || uri.scheme == 'telegram';
|
||||
}
|
||||
|
||||
bool isTelegramOAuthHost(String host) {
|
||||
return host == 'oauth.telegram.org' ||
|
||||
host == 'telegram.org' ||
|
||||
host.endsWith('.telegram.org');
|
||||
}
|
||||
|
||||
/// Opens the cabinet in an in-app browser and resolves with the JWT once the
|
||||
/// user has signed in through the Telegram Login Widget.
|
||||
Future<String?> showTelegramLogin({
|
||||
required BuildContext context,
|
||||
required String cabinetUrl,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push<String>(
|
||||
CupertinoPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => TelegramLoginScreen(cabinetUrl: cabinetUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class TelegramLoginScreen extends StatefulWidget {
|
||||
const TelegramLoginScreen({super.key, required this.cabinetUrl});
|
||||
|
||||
final String cabinetUrl;
|
||||
|
||||
@override
|
||||
State<TelegramLoginScreen> createState() => _TelegramLoginScreenState();
|
||||
}
|
||||
|
||||
class _TelegramLoginScreenState extends State<TelegramLoginScreen> {
|
||||
late final WebViewController _controller;
|
||||
Timer? _poll;
|
||||
bool _loading = true;
|
||||
bool _completed = false;
|
||||
bool _canGoBack = false;
|
||||
String? _error;
|
||||
|
||||
Uri get _startUri => resolveCabinetLoginUri(widget.cabinetUrl);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(telegramWebViewUserAgent)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..addJavaScriptChannel(
|
||||
telegramAuthChannel,
|
||||
onMessageReceived: (message) => _onToken(message.message),
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
onPageStarted: (_) {
|
||||
if (mounted) setState(() => _loading = true);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
_refreshCanGoBack();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
_probe();
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted || _completed) return;
|
||||
// Subframe errors (the Telegram iframe) must not kill the page.
|
||||
if (error.isForMainFrame == false) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.description;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_configureAndroid();
|
||||
_controller.loadRequest(_startUri);
|
||||
|
||||
// The widget writes the token after an async callback, so polling is more
|
||||
// reliable than a single probe on page load.
|
||||
_poll = Timer.periodic(const Duration(milliseconds: 700), (_) => _probe());
|
||||
}
|
||||
|
||||
Future<void> _configureAndroid() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||
|
||||
final platform = _controller.platform;
|
||||
if (platform is! AndroidWebViewController) return;
|
||||
|
||||
final cookies = AndroidWebViewCookieManager(
|
||||
const PlatformWebViewCookieManagerCreationParams(),
|
||||
);
|
||||
await cookies.setAcceptThirdPartyCookies(platform, true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _probe() async {
|
||||
if (_completed) return;
|
||||
try {
|
||||
await _controller.runJavaScript(telegramTokenProbeJs);
|
||||
} on PlatformException {
|
||||
// The page may be mid-navigation; the next tick retries.
|
||||
}
|
||||
}
|
||||
|
||||
Future<NavigationDecision> _onNavigationRequest(NavigationRequest request) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) return NavigationDecision.navigate;
|
||||
|
||||
if (isExternalAuthScheme(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
Future<void> _refreshCanGoBack() async {
|
||||
final canGoBack = await _controller.canGoBack();
|
||||
if (mounted && canGoBack != _canGoBack) {
|
||||
setState(() => _canGoBack = canGoBack);
|
||||
}
|
||||
}
|
||||
|
||||
void _onToken(String raw) {
|
||||
if (_completed) return;
|
||||
|
||||
final token = parseTelegramAuthMessage(raw);
|
||||
if (token == null) return;
|
||||
|
||||
_completed = true;
|
||||
_poll?.cancel();
|
||||
Navigator.of(context).pop(token);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: 'Вход через Telegram',
|
||||
subtitle: _startUri.host,
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const AppText.body('Закрыть', color: AppColors.accent),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_canGoBack)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () async {
|
||||
await _controller.goBack();
|
||||
await _refreshCanGoBack();
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.back, color: AppColors.accent),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () {
|
||||
setState(() => _error = null);
|
||||
_controller.reload();
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _error != null
|
||||
? AppErrorView(
|
||||
message: _error!,
|
||||
onRetry: () {
|
||||
setState(() => _error = null);
|
||||
_controller.reload();
|
||||
},
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_loading) const Center(child: AppSpinner()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/data/models/auth_user.dart';
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
import 'package:please_pay_me/features/auth/telegram_login.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
const yandexAuthorizeHost = 'oauth.yandex.ru';
|
||||
|
||||
/// Same platforms as Telegram WebView login.
|
||||
bool get isYandexWebLoginSupported => isTelegramWebLoginSupported;
|
||||
|
||||
class YandexAuthProvider {
|
||||
const YandexAuthProvider({
|
||||
required this.enabled,
|
||||
required this.clientId,
|
||||
this.redirectUri,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final String clientId;
|
||||
final String? redirectUri;
|
||||
|
||||
bool get usable => enabled && clientId.isNotEmpty;
|
||||
|
||||
factory YandexAuthProvider.fromJson(Map<String, dynamic> json) {
|
||||
return YandexAuthProvider(
|
||||
enabled: asBool(json['enabled']),
|
||||
clientId: asString(json['client_id']),
|
||||
redirectUri: asStringOrNull(json['redirect_uri']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthProviders {
|
||||
const AuthProviders({this.yandex});
|
||||
|
||||
final YandexAuthProvider? yandex;
|
||||
|
||||
factory AuthProviders.fromJson(Map<String, dynamic> json) {
|
||||
final raw = json['yandex'];
|
||||
if (raw is! Map) return const AuthProviders();
|
||||
return AuthProviders(yandex: YandexAuthProvider.fromJson(asMap(raw)));
|
||||
}
|
||||
}
|
||||
|
||||
class YandexOAuthCallback {
|
||||
const YandexOAuthCallback({this.code, this.error});
|
||||
|
||||
final String? code;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
Uri yandexAuthorizeUri({
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
}) {
|
||||
return Uri.https(yandexAuthorizeHost, '/authorize', {
|
||||
'response_type': 'code',
|
||||
'client_id': clientId,
|
||||
'redirect_uri': redirectUri,
|
||||
'force_confirm': 'yes',
|
||||
});
|
||||
}
|
||||
|
||||
/// Callback registered in the Yandex app: origin + trailing slash.
|
||||
String cabinetYandexRedirectUri(String cabinetUrl, {String? configured}) {
|
||||
if (configured != null && configured.isNotEmpty) {
|
||||
final parsed = Uri.tryParse(configured);
|
||||
if (parsed != null && parsed.host.toLowerCase() == Uri.parse(resolveCabinetOrigin(cabinetUrl)).host.toLowerCase()) {
|
||||
return configured;
|
||||
}
|
||||
}
|
||||
return '${resolveCabinetOrigin(cabinetUrl)}/';
|
||||
}
|
||||
|
||||
/// @deprecated use [cabinetYandexRedirectUri]
|
||||
String cabinetLoginRedirectUri(String cabinetUrl) => cabinetYandexRedirectUri(cabinetUrl);
|
||||
|
||||
/// True when [uri] is the registered cabinet callback (code or error).
|
||||
YandexOAuthCallback? parseYandexCallback(Uri uri, {required String redirectUri}) {
|
||||
final expected = Uri.tryParse(redirectUri);
|
||||
if (expected == null) return null;
|
||||
if (uri.host.toLowerCase() != expected.host.toLowerCase()) return null;
|
||||
|
||||
final expectedPath = expected.path.isEmpty ? '/' : expected.path;
|
||||
if (_normalizePath(uri.path) != _normalizePath(expectedPath)) return null;
|
||||
|
||||
final code = uri.queryParameters['code']?.trim();
|
||||
final error = uri.queryParameters['error_description']?.trim() ??
|
||||
uri.queryParameters['error']?.trim();
|
||||
|
||||
if ((code == null || code.isEmpty) && (error == null || error.isEmpty)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return YandexOAuthCallback(
|
||||
code: code == null || code.isEmpty ? null : code,
|
||||
error: error == null || error.isEmpty ? null : error,
|
||||
);
|
||||
}
|
||||
|
||||
String _normalizePath(String path) {
|
||||
if (path.isEmpty) return '/';
|
||||
return path.length > 1 && path.endsWith('/') ? path.substring(0, path.length - 1) : path;
|
||||
}
|
||||
|
||||
Future<AuthProviders> fetchAuthProviders(
|
||||
String apiBaseUrl, {
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final client = ApiClient(
|
||||
baseUrl: apiBaseUrl,
|
||||
tokenProvider: () => null,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
return AuthProviders.fromJson(await client.getJson('/api/auth/providers'));
|
||||
}
|
||||
|
||||
Future<String> exchangeYandexCode({
|
||||
required String apiBaseUrl,
|
||||
required String code,
|
||||
required String redirectUri,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final client = ApiClient(
|
||||
baseUrl: apiBaseUrl,
|
||||
tokenProvider: () => null,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
final session = AuthSession.fromJson(
|
||||
await client.postJson(
|
||||
'/api/auth/yandex',
|
||||
body: {'code': code, 'redirect_uri': redirectUri},
|
||||
),
|
||||
);
|
||||
return session.accessToken;
|
||||
}
|
||||
|
||||
/// Opens Yandex OAuth in a WebView, intercepts the cabinet callback, exchanges
|
||||
/// the code on the API and returns a JWT.
|
||||
Future<String?> showYandexLogin({
|
||||
required BuildContext context,
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
required Future<String> Function(String code) exchangeCode,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push<String>(
|
||||
CupertinoPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => YandexLoginScreen(
|
||||
clientId: clientId,
|
||||
redirectUri: redirectUri,
|
||||
exchangeCode: exchangeCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class YandexLoginScreen extends StatefulWidget {
|
||||
const YandexLoginScreen({
|
||||
super.key,
|
||||
required this.clientId,
|
||||
required this.redirectUri,
|
||||
required this.exchangeCode,
|
||||
});
|
||||
|
||||
final String clientId;
|
||||
final String redirectUri;
|
||||
final Future<String> Function(String code) exchangeCode;
|
||||
|
||||
@override
|
||||
State<YandexLoginScreen> createState() => _YandexLoginScreenState();
|
||||
}
|
||||
|
||||
class _YandexLoginScreenState extends State<YandexLoginScreen> {
|
||||
late final WebViewController _controller;
|
||||
bool _loading = true;
|
||||
bool _completed = false;
|
||||
String? _error;
|
||||
|
||||
Uri get _startUri => yandexAuthorizeUri(
|
||||
clientId: widget.clientId,
|
||||
redirectUri: widget.redirectUri,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(telegramWebViewUserAgent)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
onPageStarted: (url) {
|
||||
if (mounted) setState(() => _loading = true);
|
||||
_tryFinish(url);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted || _completed) return;
|
||||
if (error.isForMainFrame == false) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.description;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_configureAndroid();
|
||||
_controller.loadRequest(_startUri);
|
||||
}
|
||||
|
||||
Future<void> _configureAndroid() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||
|
||||
final platform = _controller.platform;
|
||||
if (platform is! AndroidWebViewController) return;
|
||||
|
||||
final cookies = AndroidWebViewCookieManager(
|
||||
const PlatformWebViewCookieManagerCreationParams(),
|
||||
);
|
||||
await cookies.setAcceptThirdPartyCookies(platform, true);
|
||||
}
|
||||
|
||||
Future<NavigationDecision> _onNavigationRequest(NavigationRequest request) async {
|
||||
if (await _tryFinish(request.url)) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
Future<bool> _tryFinish(String url) async {
|
||||
if (_completed) return true;
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return false;
|
||||
|
||||
final callback = parseYandexCallback(uri, redirectUri: widget.redirectUri);
|
||||
if (callback == null) return false;
|
||||
|
||||
if (callback.error != null) {
|
||||
_completed = true;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = callback.error;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
final code = callback.code;
|
||||
if (code == null) return false;
|
||||
|
||||
_completed = true;
|
||||
if (mounted) setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final token = await widget.exchangeCode(code);
|
||||
if (!mounted) return true;
|
||||
Navigator.of(context).pop(token);
|
||||
} on ApiException catch (error) {
|
||||
if (!mounted) return true;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.message;
|
||||
_completed = false;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: 'Вход через Яндекс',
|
||||
subtitle: yandexAuthorizeHost,
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const AppText.body('Закрыть', color: AppColors.accent),
|
||||
),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_completed = false;
|
||||
});
|
||||
_controller.loadRequest(_startUri);
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _error != null
|
||||
? AppErrorView(
|
||||
message: _error!,
|
||||
onRetry: () {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_completed = false;
|
||||
});
|
||||
_controller.loadRequest(_startUri);
|
||||
},
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_loading) const Center(child: AppSpinner()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/data/models/budget.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
|
||||
/// Create / edit form for an envelope.
|
||||
class BudgetFormSheet extends StatefulWidget {
|
||||
const BudgetFormSheet({super.key, required this.onSubmit, this.initial});
|
||||
|
||||
final BudgetStatus? initial;
|
||||
|
||||
final Future<String?> Function({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
required bool resetExpenses,
|
||||
}) onSubmit;
|
||||
|
||||
@override
|
||||
State<BudgetFormSheet> createState() => _BudgetFormSheetState();
|
||||
}
|
||||
|
||||
class _BudgetFormSheetState extends State<BudgetFormSheet> {
|
||||
late final _nameController = TextEditingController(
|
||||
text: widget.initial?.budget.name ?? '',
|
||||
);
|
||||
late final _amountController = TextEditingController(
|
||||
text: widget.initial == null
|
||||
? ''
|
||||
: widget.initial!.budget.totalAmount.toStringAsFixed(0),
|
||||
);
|
||||
|
||||
late DateTime _startDate = widget.initial?.budget.startDate ?? DateTime.now();
|
||||
late DateTime _endDate =
|
||||
widget.initial?.budget.endDate ?? DateTime.now().add(const Duration(days: 14));
|
||||
|
||||
bool _resetExpenses = false;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isEditing => widget.initial != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_amountController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
final amount = double.tryParse(
|
||||
_amountController.text.trim().replaceAll(',', '.').replaceAll(' ', ''),
|
||||
);
|
||||
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = 'Введите название бюджета');
|
||||
return;
|
||||
}
|
||||
if (amount == null || amount <= 0) {
|
||||
setState(() => _error = 'Введите сумму больше нуля');
|
||||
return;
|
||||
}
|
||||
if (!_endDate.isAfter(_startDate)) {
|
||||
setState(() => _error = 'Дата окончания должна быть позже начала');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final error = await widget.onSubmit(
|
||||
name: name,
|
||||
totalAmount: amount,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
resetExpenses: _resetExpenses,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error != null) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: _isEditing ? 'Бюджет' : 'Новый бюджет',
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(false),
|
||||
child: const AppText.body('Отмена', color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s4, bottom: AppSpacing.s6),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppTextField(
|
||||
label: 'Название',
|
||||
placeholder: 'До аванса',
|
||||
controller: _nameController,
|
||||
enabled: !_saving,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppTextField(
|
||||
label: 'Сумма на период',
|
||||
placeholder: '0',
|
||||
controller: _amountController,
|
||||
enabled: !_saving,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppListSection(
|
||||
header: 'Период',
|
||||
footer: 'Дневной лимит = остаток ÷ количество оставшихся дней.',
|
||||
children: [
|
||||
AppListTile(
|
||||
title: 'Начало',
|
||||
value: formatShortDate(_startDate),
|
||||
onTap: _saving ? null : () => _pickDate(isStart: true),
|
||||
),
|
||||
AppListTile(
|
||||
title: 'Окончание',
|
||||
value: formatShortDate(_endDate),
|
||||
onTap: _saving ? null : () => _pickDate(isStart: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isEditing) ...[
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppListSection(
|
||||
footer: 'Сбросить траты — обнулить потраченное по этому бюджету.',
|
||||
children: [
|
||||
AppSwitchRow(
|
||||
title: 'Сбросить траты',
|
||||
value: _resetExpenses,
|
||||
onChanged: _saving ? null : (v) => setState(() => _resetExpenses = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s3,
|
||||
AppSpacing.gutter,
|
||||
0,
|
||||
),
|
||||
child: AppText.footnote(_error!, color: AppColors.systemRed),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: AppButton(
|
||||
label: _isEditing ? 'Сохранить' : 'Создать бюджет',
|
||||
loading: _saving,
|
||||
onPressed: _submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final picked = await showAppDatePicker(
|
||||
context: context,
|
||||
initialDate: isStart ? _startDate : _endDate,
|
||||
minimumDate: isStart ? null : _startDate,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startDate = picked;
|
||||
if (!_endDate.isAfter(_startDate)) {
|
||||
_endDate = _startDate.add(const Duration(days: 14));
|
||||
}
|
||||
} else {
|
||||
_endDate = picked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the sheet wired to [BudgetsController].
|
||||
Future<void> showBudgetFormSheet({
|
||||
required BuildContext context,
|
||||
required BudgetsController controller,
|
||||
BudgetStatus? initial,
|
||||
}) async {
|
||||
final saved = await showAppFormSheet<bool>(
|
||||
context: context,
|
||||
builder: (_) => BudgetFormSheet(
|
||||
initial: initial,
|
||||
onSubmit: ({
|
||||
required name,
|
||||
required totalAmount,
|
||||
required startDate,
|
||||
required endDate,
|
||||
required resetExpenses,
|
||||
}) {
|
||||
if (initial == null) {
|
||||
return controller.create(
|
||||
name: name,
|
||||
totalAmount: totalAmount,
|
||||
endDate: endDate,
|
||||
startDate: startDate,
|
||||
);
|
||||
}
|
||||
return controller.update(
|
||||
budgetId: initial.budget.id,
|
||||
name: name,
|
||||
totalAmount: totalAmount,
|
||||
endDate: endDate,
|
||||
startDate: startDate,
|
||||
resetExpenses: resetExpenses,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (saved == true && context.mounted) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: initial == null ? 'Бюджет создан' : 'Бюджет обновлён',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:please_pay_me/core/state/async_value.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/data/models/budget.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
/// Source of truth for budgets: the overview, the budget list and the expense
|
||||
/// form all read the selected envelope from here.
|
||||
class BudgetsController extends ChangeNotifier {
|
||||
BudgetsController({
|
||||
required BudgetRepository budgets,
|
||||
required ExpenseRepository expenses,
|
||||
}) : _budgets = budgets,
|
||||
_expenses = expenses;
|
||||
|
||||
final BudgetRepository _budgets;
|
||||
final ExpenseRepository _expenses;
|
||||
|
||||
AsyncValue<List<BudgetStatus>> _state = const AsyncValue.loading();
|
||||
bool _mutating = false;
|
||||
|
||||
AsyncValue<List<BudgetStatus>> get state => _state;
|
||||
|
||||
/// True while a write is in flight — used to disable buttons.
|
||||
bool get isMutating => _mutating;
|
||||
|
||||
List<BudgetStatus> get items => _state.valueOrNull ?? const [];
|
||||
|
||||
/// Currently selected envelope; `null` when the user has no budgets at all.
|
||||
BudgetStatus? get selected {
|
||||
final all = items;
|
||||
if (all.isEmpty) return null;
|
||||
for (final status in all) {
|
||||
if (status.selected) return status;
|
||||
}
|
||||
return all.first;
|
||||
}
|
||||
|
||||
bool get hasBudgets => items.isNotEmpty;
|
||||
|
||||
Future<void> load({bool silent = false}) async {
|
||||
if (!silent) {
|
||||
_state = const AsyncValue.loading();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
_state = AsyncValue.data(await _budgets.list());
|
||||
} on ApiException catch (error) {
|
||||
_state = AsyncValue.error(error.message);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> select(int budgetId) {
|
||||
return _mutate(() => _budgets.select(budgetId));
|
||||
}
|
||||
|
||||
Future<String?> setActive(int budgetId, {required bool isActive}) {
|
||||
return _mutate(() => _budgets.setActive(budgetId, isActive: isActive));
|
||||
}
|
||||
|
||||
Future<String?> create({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime endDate,
|
||||
DateTime? startDate,
|
||||
}) {
|
||||
return _mutate(
|
||||
() => _budgets.create(
|
||||
name: name,
|
||||
totalAmount: totalAmount,
|
||||
endDate: endDate,
|
||||
startDate: startDate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> update({
|
||||
required int budgetId,
|
||||
String? name,
|
||||
double? totalAmount,
|
||||
DateTime? endDate,
|
||||
DateTime? startDate,
|
||||
bool resetExpenses = false,
|
||||
}) {
|
||||
return _mutate(
|
||||
() => _budgets.update(
|
||||
budgetId: budgetId,
|
||||
name: name,
|
||||
totalAmount: totalAmount,
|
||||
endDate: endDate,
|
||||
startDate: startDate,
|
||||
resetExpenses: resetExpenses,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> delete(int budgetId) => _mutate(() => _budgets.delete(budgetId));
|
||||
|
||||
Future<String?> addExpense({
|
||||
required double amount,
|
||||
String? note,
|
||||
DateTime? spentAt,
|
||||
int? budgetId,
|
||||
}) {
|
||||
return _mutate(
|
||||
() => _expenses.create(
|
||||
amount: amount,
|
||||
note: note,
|
||||
spentAt: spentAt,
|
||||
budgetId: budgetId ?? selected?.budget.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> undoLastExpense() {
|
||||
return _mutate(() => _expenses.undoLast(budgetId: selected?.budget.id));
|
||||
}
|
||||
|
||||
/// Runs a write, reloads the list and returns an error message or `null`.
|
||||
Future<String?> _mutate(Future<void> Function() action) async {
|
||||
_mutating = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await action();
|
||||
await load(silent: true);
|
||||
return null;
|
||||
} on ApiException catch (error) {
|
||||
return error.message;
|
||||
} finally {
|
||||
_mutating = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/data/models/budget.dart';
|
||||
import 'package:please_pay_me/features/budgets/budget_form_sheet.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// All envelopes: pick the active one, edit, archive or delete.
|
||||
class BudgetsScreen extends StatelessWidget {
|
||||
const BudgetsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<BudgetsController>();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
slivers: [
|
||||
AppLargeNavBar(
|
||||
title: 'Бюджеты',
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => showBudgetFormSheet(context: context, controller: controller),
|
||||
child: const AppIcon(CupertinoIcons.add_circled, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
CupertinoSliverRefreshControl(onRefresh: () => controller.load(silent: true)),
|
||||
SliverToBoxAdapter(
|
||||
child: controller.state.map(
|
||||
loading: () => AppListSection(
|
||||
children: List.generate(3, (_) => const AppSkeletonRow()),
|
||||
),
|
||||
error: (message) => AppErrorView(message: message, onRetry: controller.load),
|
||||
data: (items) => items.isEmpty
|
||||
? AppEmptyState(
|
||||
icon: CupertinoIcons.money_rubl_circle,
|
||||
title: 'Бюджетов нет',
|
||||
message: 'Создайте первый конверт до следующей зарплаты.',
|
||||
actionLabel: 'Создать бюджет',
|
||||
onAction: () =>
|
||||
showBudgetFormSheet(context: context, controller: controller),
|
||||
)
|
||||
: _BudgetsList(items: items, controller: controller),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BudgetsList extends StatelessWidget {
|
||||
const _BudgetsList({required this.items, required this.controller});
|
||||
|
||||
final List<BudgetStatus> items;
|
||||
final BudgetsController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = items.where((status) => !status.isExpired).toList();
|
||||
final archived = items.where((status) => status.isExpired).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (active.isNotEmpty)
|
||||
AppListSection(
|
||||
header: 'Активные',
|
||||
footer: 'Нажмите, чтобы сделать бюджет текущим.',
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
for (final status in active)
|
||||
_BudgetRow(status: status, controller: controller),
|
||||
],
|
||||
),
|
||||
if (archived.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppListSection(
|
||||
header: 'Завершённые',
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
for (final status in archived)
|
||||
_BudgetRow(status: status, controller: controller),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BudgetRow extends StatelessWidget {
|
||||
const _BudgetRow({required this.status, required this.controller});
|
||||
|
||||
final BudgetStatus status;
|
||||
final BudgetsController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currency = status.budget.currency;
|
||||
final subtitle = status.isExpired
|
||||
? 'Завершён ${formatShortDate(status.budget.endDate)}'
|
||||
: '${formatDaysLeft(status.daysLeft)} · лимит ${formatMoney(status.dailyLimit, currency: currency)}';
|
||||
|
||||
return AppListTile(
|
||||
leading: AppIconBadge(
|
||||
icon: status.selected ? CupertinoIcons.checkmark_alt : CupertinoIcons.tray_full,
|
||||
color: status.selected
|
||||
? AppColors.accent
|
||||
: status.isExpired
|
||||
? AppColors.systemGray
|
||||
: AppColors.systemOrange,
|
||||
),
|
||||
title: status.budget.name,
|
||||
subtitle: subtitle,
|
||||
value: formatMoney(status.remaining, currency: currency),
|
||||
onTap: () => _openActions(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openActions(BuildContext context) async {
|
||||
final journal = context.read<JournalController>();
|
||||
final index = await showAppActionSheet(
|
||||
context: context,
|
||||
title: status.budget.name,
|
||||
message: 'Остаток ${formatMoney(status.remaining, currency: status.budget.currency)}',
|
||||
actions: [
|
||||
if (!status.selected) const AppActionSheetAction(label: 'Сделать текущим', isDefault: true),
|
||||
const AppActionSheetAction(label: 'Редактировать'),
|
||||
AppActionSheetAction(label: status.budget.isActive ? 'В архив' : 'Вернуть из архива'),
|
||||
const AppActionSheetAction(label: 'Удалить', destructive: true),
|
||||
],
|
||||
);
|
||||
|
||||
if (index == null || !context.mounted) return;
|
||||
|
||||
final actions = <String>[
|
||||
if (!status.selected) 'select',
|
||||
'edit',
|
||||
'archive',
|
||||
'delete',
|
||||
];
|
||||
|
||||
switch (actions[index]) {
|
||||
case 'select':
|
||||
final error = await controller.select(status.budget.id);
|
||||
journal.bindBudget(controller.selected?.budget.id);
|
||||
await journal.load(silent: true);
|
||||
if (context.mounted) {
|
||||
await showAppToast(context, message: error ?? 'Бюджет выбран');
|
||||
}
|
||||
case 'edit':
|
||||
await showBudgetFormSheet(
|
||||
context: context,
|
||||
controller: controller,
|
||||
initial: status,
|
||||
);
|
||||
case 'archive':
|
||||
final error = await controller.setActive(
|
||||
status.budget.id,
|
||||
isActive: !status.budget.isActive,
|
||||
);
|
||||
if (context.mounted) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: error ?? (status.budget.isActive ? 'Бюджет в архиве' : 'Бюджет активен'),
|
||||
);
|
||||
}
|
||||
case 'delete':
|
||||
await _confirmDelete(context, journal);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, JournalController journal) async {
|
||||
final confirmed = await showAppAlert(
|
||||
context: context,
|
||||
title: 'Удалить «${status.budget.name}»?',
|
||||
message: 'Вместе с бюджетом удалятся все его операции.',
|
||||
confirmLabel: 'Удалить',
|
||||
cancelLabel: 'Отмена',
|
||||
destructive: true,
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
final error = await controller.delete(status.budget.id);
|
||||
journal.bindBudget(controller.selected?.budget.id);
|
||||
await journal.load(silent: true);
|
||||
|
||||
if (context.mounted) {
|
||||
await showAppToast(context, message: error ?? 'Бюджет удалён');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
|
||||
import 'package:please_pay_me/features/expenses/expense_form_sheet.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_controller.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Opens the expense form and keeps the journal in sync on success.
|
||||
Future<void> showExpenseFormSheet({
|
||||
required BuildContext context,
|
||||
required BudgetsController controller,
|
||||
}) async {
|
||||
final selected = controller.selected;
|
||||
final journal = context.read<JournalController>();
|
||||
|
||||
final saved = await showAppFormSheet<bool>(
|
||||
context: context,
|
||||
builder: (_) => ExpenseFormSheet(
|
||||
budgetName: selected?.budget.name,
|
||||
remainingToday: selected?.remainingToday,
|
||||
onSubmit: ({required amount, note, required spentAt}) => controller.addExpense(
|
||||
amount: amount,
|
||||
note: note,
|
||||
spentAt: spentAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (saved != true) return;
|
||||
|
||||
await journal.load(silent: true);
|
||||
if (context.mounted) {
|
||||
await showAppToast(context, message: 'Трата записана');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> undoLastExpense({
|
||||
required BuildContext context,
|
||||
required BudgetsController controller,
|
||||
}) async {
|
||||
final journal = context.read<JournalController>();
|
||||
final confirmed = await showAppAlert(
|
||||
context: context,
|
||||
title: 'Отменить последнюю трату?',
|
||||
message: 'Операция будет удалена из текущего бюджета.',
|
||||
confirmLabel: 'Отменить трату',
|
||||
cancelLabel: 'Закрыть',
|
||||
destructive: true,
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
final error = await controller.undoLastExpense();
|
||||
await journal.load(silent: true);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showAppToast(
|
||||
context,
|
||||
message: error ?? 'Последняя трата удалена',
|
||||
icon: error == null
|
||||
? CupertinoIcons.arrow_uturn_left_circle_fill
|
||||
: CupertinoIcons.exclamationmark_circle_fill,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared row renderer so the journal and the overview look identical.
|
||||
String expenseTitle(String? note) => note?.trim().isNotEmpty == true ? note!.trim() : 'Без комментария';
|
||||
|
||||
String expenseAmount(double amount, {String currency = 'RUB'}) {
|
||||
return formatSignedMoney(amount, currency: currency);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
|
||||
typedef ExpenseSubmit = Future<String?> Function({
|
||||
required double amount,
|
||||
String? note,
|
||||
required DateTime spentAt,
|
||||
});
|
||||
|
||||
/// Modal form for a new expense. Submits through the caller so the sheet has
|
||||
/// no knowledge of repositories.
|
||||
class ExpenseFormSheet extends StatefulWidget {
|
||||
const ExpenseFormSheet({
|
||||
super.key,
|
||||
required this.onSubmit,
|
||||
this.budgetName,
|
||||
this.remainingToday,
|
||||
});
|
||||
|
||||
final ExpenseSubmit onSubmit;
|
||||
final String? budgetName;
|
||||
final double? remainingToday;
|
||||
|
||||
@override
|
||||
State<ExpenseFormSheet> createState() => _ExpenseFormSheetState();
|
||||
}
|
||||
|
||||
class _ExpenseFormSheetState extends State<ExpenseFormSheet> {
|
||||
final _amountController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
|
||||
DateTime _date = DateTime.now();
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
static const _quickAmounts = [100.0, 250.0, 500.0, 1000.0];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double? get _amount {
|
||||
final raw = _amountController.text.trim().replaceAll(',', '.').replaceAll(' ', '');
|
||||
final value = double.tryParse(raw);
|
||||
return value != null && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final amount = _amount;
|
||||
if (amount == null) {
|
||||
setState(() => _error = 'Введите сумму больше нуля');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final note = _noteController.text.trim();
|
||||
final error = await widget.onSubmit(
|
||||
amount: amount,
|
||||
note: note.isEmpty ? null : note,
|
||||
spentAt: _date,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error != null) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: 'Новая трата',
|
||||
subtitle: widget.budgetName,
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(false),
|
||||
child: const AppText.body('Отмена', color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s4, bottom: AppSpacing.s6),
|
||||
children: [
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppText.footnote('Сумма'),
|
||||
const SizedBox(height: AppSpacing.s1),
|
||||
CupertinoTextField.borderless(
|
||||
controller: _amountController,
|
||||
autofocus: true,
|
||||
placeholder: '0',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
style: AppTypography.largeTitle.copyWith(
|
||||
color: AppColors.of(context, AppColors.label),
|
||||
),
|
||||
placeholderStyle: AppTypography.largeTitle.copyWith(
|
||||
color: AppColors.of(context, AppColors.tertiaryLabel),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
suffix: const AppText.title('₽', color: AppColors.secondaryLabel),
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
if (widget.remainingToday != null) ...[
|
||||
const SizedBox(height: AppSpacing.s2),
|
||||
AppText.footnote(
|
||||
'На сегодня осталось ${formatMoney(widget.remainingToday!)}',
|
||||
color: widget.remainingToday! < 0
|
||||
? AppColors.systemRed
|
||||
: AppColors.secondaryLabel,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final amount in _quickAmounts) ...[
|
||||
AppChip(
|
||||
label: formatMoney(amount, compact: true),
|
||||
onPressed: () => setState(() {
|
||||
_amountController.text = amount.toStringAsFixed(0);
|
||||
_error = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s2),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppListSection(
|
||||
children: [
|
||||
AppListTile(
|
||||
title: 'Дата',
|
||||
value: formatRelativeDay(_date),
|
||||
onTap: _saving ? null : _pickDate,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: AppTextField(
|
||||
controller: _noteController,
|
||||
placeholder: 'Комментарий',
|
||||
prefixIcon: CupertinoIcons.text_alignleft,
|
||||
enabled: !_saving,
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s3,
|
||||
AppSpacing.gutter,
|
||||
0,
|
||||
),
|
||||
child: AppText.footnote(_error!, color: AppColors.systemRed),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: AppButton(
|
||||
label: 'Записать трату',
|
||||
loading: _saving,
|
||||
onPressed: _submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showAppDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
maximumDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null && mounted) setState(() => _date = picked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:please_pay_me/core/state/async_value.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/data/models/expense.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
enum JournalScope {
|
||||
current('Текущий'),
|
||||
all('Все бюджеты');
|
||||
|
||||
const JournalScope(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
class ExpenseGroup {
|
||||
const ExpenseGroup({required this.day, required this.items});
|
||||
|
||||
final DateTime day;
|
||||
final List<Expense> items;
|
||||
|
||||
double get total => items.fold<double>(0, (sum, expense) => sum + expense.amount);
|
||||
}
|
||||
|
||||
/// Paginated journal of operations with day grouping.
|
||||
class JournalController extends ChangeNotifier {
|
||||
JournalController({required ExpenseRepository expenses, this.pageSize = 20})
|
||||
: _expenses = expenses;
|
||||
|
||||
final ExpenseRepository _expenses;
|
||||
final int pageSize;
|
||||
|
||||
AsyncValue<ExpensesPage> _state = const AsyncValue.loading();
|
||||
JournalScope _scope = JournalScope.current;
|
||||
int? _budgetId;
|
||||
bool _loadingMore = false;
|
||||
|
||||
AsyncValue<ExpensesPage> get state => _state;
|
||||
JournalScope get scope => _scope;
|
||||
bool get isLoadingMore => _loadingMore;
|
||||
|
||||
List<Expense> get items => _state.valueOrNull?.items ?? const [];
|
||||
|
||||
bool get hasMore => _state.valueOrNull?.hasMore ?? false;
|
||||
|
||||
double get totalSum => _state.valueOrNull?.totalSum ?? 0;
|
||||
|
||||
/// Operations bucketed by day, newest first — the journal renders one
|
||||
/// inset-grouped section per bucket.
|
||||
List<ExpenseGroup> get groups {
|
||||
final buckets = <DateTime, List<Expense>>{};
|
||||
for (final expense in items) {
|
||||
final day = DateTime(expense.spentAt.year, expense.spentAt.month, expense.spentAt.day);
|
||||
buckets.putIfAbsent(day, () => []).add(expense);
|
||||
}
|
||||
|
||||
final days = buckets.keys.toList()..sort((a, b) => b.compareTo(a));
|
||||
return [for (final day in days) ExpenseGroup(day: day, items: buckets[day]!)];
|
||||
}
|
||||
|
||||
void bindBudget(int? budgetId) {
|
||||
if (_budgetId == budgetId) return;
|
||||
_budgetId = budgetId;
|
||||
load(silent: true);
|
||||
}
|
||||
|
||||
Future<void> setScope(JournalScope scope) async {
|
||||
if (_scope == scope) return;
|
||||
_scope = scope;
|
||||
notifyListeners();
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<void> load({bool silent = false}) async {
|
||||
if (!silent) {
|
||||
_state = const AsyncValue.loading();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
_state = AsyncValue.data(await _fetch(1));
|
||||
} on ApiException catch (error) {
|
||||
_state = AsyncValue.error(error.message);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = _state.valueOrNull;
|
||||
if (current == null || !current.hasMore || _loadingMore) return;
|
||||
|
||||
_loadingMore = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final next = await _fetch(current.page + 1);
|
||||
_state = AsyncValue.data(
|
||||
next.copyWithItems([...current.items, ...next.items]),
|
||||
);
|
||||
} on ApiException catch (error) {
|
||||
_state = AsyncValue.error(error.message);
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ExpensesPage> _fetch(int page) {
|
||||
return _expenses.page(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
budgetId: _scope == JournalScope.all ? null : _budgetId,
|
||||
all: _scope == JournalScope.all,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
|
||||
import 'package:please_pay_me/features/expenses/expense_actions.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Operations grouped by day, with current-budget / all-budgets scope.
|
||||
class JournalScreen extends StatelessWidget {
|
||||
const JournalScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final journal = context.watch<JournalController>();
|
||||
final budgets = context.watch<BudgetsController>();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
slivers: [
|
||||
AppLargeNavBar(
|
||||
title: 'Журнал',
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: budgets.hasBudgets
|
||||
? () => showExpenseFormSheet(context: context, controller: budgets)
|
||||
: null,
|
||||
child: const AppIcon(CupertinoIcons.add_circled, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
CupertinoSliverRefreshControl(onRefresh: () => journal.load(silent: true)),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2, bottom: AppSpacing.s4),
|
||||
child: AppSegmentedControl(
|
||||
labels: JournalScope.values.map((scope) => scope.label).toList(),
|
||||
index: JournalScope.values.indexOf(journal.scope),
|
||||
onChanged: (index) => journal.setScope(JournalScope.values[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: journal.state.map(
|
||||
loading: () => AppListSection(
|
||||
children: List.generate(4, (_) => const AppSkeletonRow(hasLeading: false)),
|
||||
),
|
||||
error: (message) => AppErrorView(message: message, onRetry: journal.load),
|
||||
data: (_) => _JournalBody(journal: journal),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JournalBody extends StatelessWidget {
|
||||
const _JournalBody({required this.journal});
|
||||
|
||||
final JournalController journal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = journal.groups;
|
||||
|
||||
if (groups.isEmpty) {
|
||||
final budgets = context.read<BudgetsController>();
|
||||
return AppEmptyState(
|
||||
icon: CupertinoIcons.doc_text,
|
||||
title: 'Операций пока нет',
|
||||
message: journal.scope == JournalScope.all
|
||||
? 'Как только появится первая трата, она появится здесь.'
|
||||
: 'В текущем бюджете ещё ничего не потрачено.',
|
||||
actionLabel: budgets.hasBudgets ? 'Добавить трату' : null,
|
||||
onAction: budgets.hasBudgets
|
||||
? () => showExpenseFormSheet(context: context, controller: budgets)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppText.footnote('Всего операций'),
|
||||
AppText.title('${journal.state.valueOrNull?.totalCount ?? 0}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppText.footnote('Сумма'),
|
||||
AppText.title(formatMoney(journal.totalSum)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
for (final group in groups) ...[
|
||||
AppListSection(
|
||||
header: formatRelativeDay(group.day),
|
||||
footer: 'Итого за день: ${formatMoney(group.total)}',
|
||||
children: [
|
||||
for (final expense in group.items)
|
||||
AppListTile(
|
||||
title: expenseTitle(expense.note),
|
||||
subtitle: formatWeekday(expense.spentAt),
|
||||
value: expenseAmount(expense.amount),
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
],
|
||||
if (journal.hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: AppButton(
|
||||
label: 'Показать ещё',
|
||||
style: AppButtonStyle.gray,
|
||||
loading: journal.isLoadingMore,
|
||||
onPressed: journal.loadMore,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/legal/legal_links.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
|
||||
class LegalAcceptance {
|
||||
const LegalAcceptance({this.offer = false, this.consent = false});
|
||||
|
||||
final bool offer;
|
||||
final bool consent;
|
||||
|
||||
bool get accepted => offer && consent;
|
||||
|
||||
LegalAcceptance copyWith({bool? offer, bool? consent}) {
|
||||
return LegalAcceptance(offer: offer ?? this.offer, consent: consent ?? this.consent);
|
||||
}
|
||||
}
|
||||
|
||||
class LegalConsentBlock extends StatelessWidget {
|
||||
const LegalConsentBlock({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.cabinetUrl,
|
||||
});
|
||||
|
||||
final LegalAcceptance value;
|
||||
final ValueChanged<LegalAcceptance> onChanged;
|
||||
final String cabinetUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_LegalCheckRow(
|
||||
checkboxKey: const Key('legal-offer-check'),
|
||||
value: value.offer,
|
||||
onChanged: (next) => onChanged(value.copyWith(offer: next)),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: AppColors.of(context, AppColors.secondaryLabel),
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Я принимаю условия '),
|
||||
_LinkSpan(
|
||||
text: 'Пользовательского соглашения',
|
||||
color: AppColors.of(context, AppColors.accent),
|
||||
onTap: () => openLegalDocument(context, LegalLinks.resolve(cabinetUrl, LegalLinks.offer)),
|
||||
),
|
||||
const TextSpan(text: ' (публичной оферты).'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
_LegalCheckRow(
|
||||
checkboxKey: const Key('legal-consent-check'),
|
||||
value: value.consent,
|
||||
onChanged: (next) => onChanged(value.copyWith(consent: next)),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: AppColors.of(context, AppColors.secondaryLabel),
|
||||
),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text:
|
||||
'Я даю согласие на обработку моих персональных данных (email, аватар, данные о транзакциях), полученных от сервиса Яндекс и введённых мной, в целях предоставления доступа к Сервису. Согласие действует до его отзыва. ',
|
||||
),
|
||||
_LinkSpan(
|
||||
text: 'Текст согласия',
|
||||
color: AppColors.of(context, AppColors.accent),
|
||||
onTap: () =>
|
||||
openLegalDocument(context, LegalLinks.resolve(cabinetUrl, LegalLinks.consent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkSpan extends WidgetSpan {
|
||||
_LinkSpan({required String text, required Color color, required VoidCallback onTap})
|
||||
: super(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: color,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LegalCheckRow extends StatelessWidget {
|
||||
const _LegalCheckRow({
|
||||
this.checkboxKey,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Key? checkboxKey;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
key: checkboxKey,
|
||||
value ? CupertinoIcons.checkmark_square_fill : CupertinoIcons.square,
|
||||
size: 22,
|
||||
color: AppColors.of(context, value ? AppColors.accent : AppColors.systemGray),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s2),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/data/models/budget.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
import 'package:please_pay_me/features/budgets/budget_form_sheet.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
|
||||
import 'package:please_pay_me/features/expenses/expense_actions.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_controller.dart';
|
||||
import 'package:please_pay_me/features/work/jobs_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Home tab: current envelope, today's allowance and quick actions.
|
||||
class OverviewScreen extends StatelessWidget {
|
||||
const OverviewScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final budgets = context.watch<BudgetsController>();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
slivers: [
|
||||
const AppLargeNavBar(title: 'Обзор'),
|
||||
CupertinoSliverRefreshControl(
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
budgets.load(silent: true),
|
||||
context.read<JobsController>().load(silent: true),
|
||||
context.read<JournalController>().load(silent: true),
|
||||
]);
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: budgets.state.map(
|
||||
loading: () => const AppLoadingView(),
|
||||
error: (message) => AppErrorView(message: message, onRetry: budgets.load),
|
||||
data: (_) {
|
||||
final selected = budgets.selected;
|
||||
if (selected == null) return _NoBudgets(controller: budgets);
|
||||
return _OverviewBody(status: selected);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoBudgets extends StatelessWidget {
|
||||
const _NoBudgets({required this.controller});
|
||||
|
||||
final BudgetsController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppEmptyState(
|
||||
icon: CupertinoIcons.money_rubl_circle,
|
||||
title: 'Бюджета пока нет',
|
||||
message: 'Создайте конверт до следующей зарплаты — приложение посчитает дневной лимит.',
|
||||
actionLabel: 'Создать бюджет',
|
||||
onAction: () => showBudgetFormSheet(context: context, controller: controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverviewBody extends StatelessWidget {
|
||||
const _OverviewBody({required this.status});
|
||||
|
||||
final BudgetStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final budgets = context.watch<BudgetsController>();
|
||||
final currency = status.budget.currency;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: AppText.headline(status.budget.name)),
|
||||
AppChip(
|
||||
label: status.isExpired
|
||||
? 'Завершён'
|
||||
: formatDaysLeft(status.daysLeft).replaceFirst('осталось ', ''),
|
||||
selected: !status.isExpired,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
const AppText.footnote('Остаток бюджета'),
|
||||
AppText.largeTitle(
|
||||
formatMoney(status.remaining, currency: currency),
|
||||
color: status.isOverBudget ? AppColors.systemRed : AppColors.label,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppProgressBar(
|
||||
value: status.spentProgress,
|
||||
color: status.isOverBudget ? AppColors.systemRed : AppColors.accent,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s2),
|
||||
AppText.footnote(
|
||||
'Потрачено ${formatMoney(status.totalSpent, currency: currency)} '
|
||||
'из ${formatMoney(status.budget.totalAmount, currency: currency)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
_TodayCard(status: status),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: 'Добавить трату',
|
||||
icon: CupertinoIcons.plus,
|
||||
onPressed: budgets.isMutating
|
||||
? null
|
||||
: () => showExpenseFormSheet(context: context, controller: budgets),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s3),
|
||||
AppButton(
|
||||
label: 'Отменить',
|
||||
style: AppButtonStyle.gray,
|
||||
expanded: false,
|
||||
onPressed: budgets.isMutating
|
||||
? null
|
||||
: () => undoLastExpense(context: context, controller: budgets),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
const _NextPaySection(),
|
||||
const _RecentOperations(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodayCard extends StatelessWidget {
|
||||
const _TodayCard({required this.status});
|
||||
|
||||
final BudgetStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currency = status.budget.currency;
|
||||
final overspent = status.isOverDaily;
|
||||
|
||||
return AppCard(
|
||||
title: 'Сегодня',
|
||||
subtitle: formatDay(status.today),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Metric(
|
||||
label: 'Дневной лимит',
|
||||
value: formatMoney(status.dailyLimit, currency: currency),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Metric(
|
||||
label: 'Потрачено',
|
||||
value: formatMoney(status.spentToday, currency: currency),
|
||||
color: overspent ? AppColors.systemRed : AppColors.label,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppProgressBar(
|
||||
value: status.dailyProgress,
|
||||
color: overspent ? AppColors.systemRed : AppColors.systemGreen,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s2),
|
||||
AppText.footnote(
|
||||
overspent
|
||||
? 'Лимит превышен на ${formatMoney(status.spentToday - status.dailyLimit, currency: currency)}'
|
||||
: 'Можно потратить ещё ${formatMoney(status.remainingToday, currency: currency)}',
|
||||
color: overspent ? AppColors.systemRed : AppColors.secondaryLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Metric extends StatelessWidget {
|
||||
const _Metric({required this.label, required this.value, this.color = AppColors.label});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppText.footnote(label),
|
||||
const SizedBox(height: 2),
|
||||
AppText.title(value, color: color),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NextPaySection extends StatelessWidget {
|
||||
const _NextPaySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jobs = context.watch<JobsController>();
|
||||
final pay = jobs.nextPay;
|
||||
if (pay == null) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.s5),
|
||||
child: AppListSection(
|
||||
header: 'Ближайшая выплата',
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.money_rubl_circle_fill,
|
||||
color: AppColors.systemGreen,
|
||||
),
|
||||
title: formatMoney(pay.amount),
|
||||
subtitle: '${formatDay(pay.date)} · ${pay.percent.round()}% оклада',
|
||||
value: _daysUntil(pay),
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _daysUntil(UpcomingPay pay) {
|
||||
final now = DateTime.now();
|
||||
final days = pay.date.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||
return switch (days) {
|
||||
<= 0 => 'сегодня',
|
||||
1 => 'завтра',
|
||||
_ => 'через ${plural(days, 'день', 'дня', 'дней')}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentOperations extends StatelessWidget {
|
||||
const _RecentOperations();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final journal = context.watch<JournalController>();
|
||||
final recent = journal.items.take(3).toList();
|
||||
if (recent.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return AppListSection(
|
||||
header: 'Последние операции',
|
||||
children: [
|
||||
for (final expense in recent)
|
||||
AppListTile(
|
||||
title: expense.note ?? 'Без комментария',
|
||||
subtitle: formatRelativeDay(expense.spentAt),
|
||||
value: formatSignedMoney(expense.amount),
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/branding/app_brand.dart';
|
||||
import 'package:please_pay_me/core/legal/legal_links.dart';
|
||||
import 'package:please_pay_me/features/auth/session_controller.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_controller.dart';
|
||||
import 'package:please_pay_me/features/work/jobs_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
final user = session.user;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
slivers: [
|
||||
const AppLargeNavBar(title: 'Профиль'),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s2,
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s5,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AppAvatar(
|
||||
initials: user?.initials,
|
||||
imageUrl: user?.photoUrl,
|
||||
radius: 32,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppText.title(user?.displayName ?? 'Гость'),
|
||||
AppText.subhead(user?.handle ?? 'не авторизован'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: AppListSection(
|
||||
header: 'Подключение',
|
||||
footer: session.isDemo
|
||||
? 'Демо-режим: данные живут только в памяти устройства.'
|
||||
: 'Данные синхронизируются с веб-кабинетом «${AppBrand.name}».',
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: AppIconBadge(
|
||||
icon: session.isDemo
|
||||
? CupertinoIcons.wrench_fill
|
||||
: CupertinoIcons.cloud_fill,
|
||||
color: session.isDemo ? AppColors.systemOrange : AppColors.systemGreen,
|
||||
),
|
||||
title: 'Режим',
|
||||
value: session.isDemo ? 'Демо' : 'Сервер',
|
||||
showChevron: false,
|
||||
),
|
||||
if (!session.isDemo)
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.link,
|
||||
color: AppColors.systemGray,
|
||||
),
|
||||
title: 'Адрес API',
|
||||
subtitle: session.baseUrl,
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s5)),
|
||||
SliverToBoxAdapter(
|
||||
child: AppListSection(
|
||||
header: 'Оформление',
|
||||
footer: 'Системная повторяет тему устройства.',
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s3,
|
||||
AppSpacing.s3,
|
||||
AppSpacing.s3,
|
||||
AppSpacing.s3,
|
||||
),
|
||||
child: AppSegmentedControl(
|
||||
padding: EdgeInsets.zero,
|
||||
labels: const ['Системная', 'Светлая', 'Тёмная'],
|
||||
index: context.watch<ThemeController>().preference.index,
|
||||
onChanged: (index) {
|
||||
context.read<ThemeController>().setPreference(
|
||||
ThemePreference.values[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s5)),
|
||||
SliverToBoxAdapter(
|
||||
child: AppListSection(
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.arrow_clockwise,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
title: 'Обновить данные',
|
||||
onTap: () => _refreshAll(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s5)),
|
||||
SliverToBoxAdapter(
|
||||
child: AppListSection(
|
||||
header: 'Правовая информация',
|
||||
footer: 'Открывается веб-версия на please-pay-me.ru.',
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.doc_text,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
title: 'Пользовательское соглашение',
|
||||
onTap: () => openLegalDocument(
|
||||
context,
|
||||
LegalLinks.resolve(session.webCabinetUrl, LegalLinks.offer),
|
||||
),
|
||||
),
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.lock_shield,
|
||||
color: AppColors.systemGray,
|
||||
),
|
||||
title: 'Политика конфиденциальности',
|
||||
onTap: () => openLegalDocument(
|
||||
context,
|
||||
LegalLinks.resolve(session.webCabinetUrl, LegalLinks.privacy),
|
||||
),
|
||||
),
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.checkmark_shield,
|
||||
color: AppColors.systemGray,
|
||||
),
|
||||
title: 'Согласие на обработку данных',
|
||||
onTap: () => openLegalDocument(
|
||||
context,
|
||||
LegalLinks.resolve(session.webCabinetUrl, LegalLinks.consent),
|
||||
),
|
||||
),
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.circle_grid_hex,
|
||||
color: AppColors.systemGray,
|
||||
),
|
||||
title: 'Политика cookie',
|
||||
onTap: () => openLegalDocument(
|
||||
context,
|
||||
LegalLinks.resolve(session.webCabinetUrl, LegalLinks.cookies),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s5)),
|
||||
SliverToBoxAdapter(
|
||||
child: AppListSection(
|
||||
footer: '${AppBrand.name} · версия 0.1.0',
|
||||
children: [
|
||||
AppListTile(
|
||||
title: 'Выйти',
|
||||
destructive: true,
|
||||
showChevron: false,
|
||||
onTap: () => _signOut(context, session),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshAll(BuildContext context) async {
|
||||
await Future.wait([
|
||||
context.read<BudgetsController>().load(silent: true),
|
||||
context.read<JournalController>().load(silent: true),
|
||||
context.read<JobsController>().load(silent: true),
|
||||
]);
|
||||
|
||||
if (context.mounted) {
|
||||
await showAppToast(context, message: 'Данные обновлены');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _signOut(BuildContext context, SessionController session) async {
|
||||
final confirmed = await showAppAlert(
|
||||
context: context,
|
||||
title: 'Выйти из аккаунта?',
|
||||
message: 'Токен будет удалён с устройства.',
|
||||
confirmLabel: 'Выйти',
|
||||
cancelLabel: 'Отмена',
|
||||
destructive: true,
|
||||
);
|
||||
|
||||
if (confirmed == true) await session.signOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/branding/app_brand.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
|
||||
/// Branded launch screen. Native Android/iOS splash uses the same background
|
||||
/// and mark so the first Flutter frame does not flash.
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _mark;
|
||||
late final Animation<double> _copy;
|
||||
late final Animation<double> _spinner;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 720),
|
||||
)..forward();
|
||||
_mark = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0, 0.55, curve: Curves.easeOutCubic),
|
||||
);
|
||||
_copy = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.28, 0.85, curve: Curves.easeOut),
|
||||
);
|
||||
_spinner = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.55, 1, curve: Curves.easeOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = CupertinoTheme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_Wash(dark: dark),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
Opacity(
|
||||
opacity: _mark.value,
|
||||
child: Transform.scale(
|
||||
scale: 0.86 + (0.14 * _mark.value),
|
||||
child: const AppBrandMark(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
Opacity(
|
||||
opacity: _copy.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 10 * (1 - _copy.value)),
|
||||
child: const Column(
|
||||
children: [
|
||||
AppText.largeTitle(AppBrand.name, textAlign: TextAlign.center),
|
||||
SizedBox(height: AppSpacing.s2),
|
||||
AppText.subhead(
|
||||
'Бюджет от зарплаты до зарплаты',
|
||||
color: AppColors.secondaryLabel,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 4),
|
||||
Opacity(
|
||||
opacity: _spinner.value,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(bottom: AppSpacing.s7),
|
||||
child: AppSpinner(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Wash extends StatelessWidget {
|
||||
const _Wash({required this.dark});
|
||||
|
||||
final bool dark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = AppColors.of(context, AppColors.accent).withValues(alpha: dark ? 0.14 : 0.1);
|
||||
return IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: const Alignment(0, -0.18),
|
||||
radius: 0.85,
|
||||
colors: [accent, const Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
import 'package:please_pay_me/features/work/jobs_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
|
||||
/// Create / edit form for a job and its payday schedule.
|
||||
class JobFormSheet extends StatefulWidget {
|
||||
const JobFormSheet({super.key, required this.onSubmit, this.initial});
|
||||
|
||||
final Job? initial;
|
||||
|
||||
final Future<String?> Function({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) onSubmit;
|
||||
|
||||
@override
|
||||
State<JobFormSheet> createState() => _JobFormSheetState();
|
||||
}
|
||||
|
||||
class _JobFormSheetState extends State<JobFormSheet> {
|
||||
late final _nameController = TextEditingController(text: widget.initial?.name ?? '');
|
||||
late final _salaryController = TextEditingController(
|
||||
text: widget.initial == null ? '' : widget.initial!.salaryAmount.toStringAsFixed(0),
|
||||
);
|
||||
|
||||
late int _firstDay = widget.initial?.payDays.firstOrNull ?? 5;
|
||||
late int? _secondDay =
|
||||
(widget.initial?.payDays.length ?? 0) > 1 ? widget.initial!.payDays[1] : 20;
|
||||
late double _firstPercent = widget.initial?.firstPayPercent ?? 40;
|
||||
late WeekendPolicy _policy = widget.initial?.weekendPolicy ?? WeekendPolicy.beforeWeekend;
|
||||
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isEditing => widget.initial != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_salaryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
final salary = double.tryParse(
|
||||
_salaryController.text.trim().replaceAll(',', '.').replaceAll(' ', ''),
|
||||
);
|
||||
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = 'Введите название работы');
|
||||
return;
|
||||
}
|
||||
if (salary == null || salary <= 0) {
|
||||
setState(() => _error = 'Введите оклад больше нуля');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final error = await widget.onSubmit(
|
||||
name: name,
|
||||
salaryAmount: salary,
|
||||
payDays: [_firstDay, if (_secondDay != null) _secondDay!],
|
||||
firstPayPercent: _secondDay == null ? 100 : _firstPercent,
|
||||
weekendPolicy: _policy,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error != null) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final salary = double.tryParse(
|
||||
_salaryController.text.trim().replaceAll(',', '.').replaceAll(' ', ''),
|
||||
) ??
|
||||
0;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: _isEditing ? 'Работа' : 'Новая работа',
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(false),
|
||||
child: const AppText.body('Отмена', color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s4, bottom: AppSpacing.s6),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppTextField(
|
||||
label: 'Название',
|
||||
placeholder: 'Основная работа',
|
||||
controller: _nameController,
|
||||
enabled: !_saving,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppTextField(
|
||||
label: 'Оклад в месяц',
|
||||
placeholder: '0',
|
||||
controller: _salaryController,
|
||||
enabled: !_saving,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppListSection(
|
||||
header: 'Дни выплат',
|
||||
footer: 'Если день выпадает на выходные, выплата сдвигается по правилу ниже.',
|
||||
children: [
|
||||
AppListTile(
|
||||
title: 'Первая выплата',
|
||||
value: '$_firstDay числа',
|
||||
onTap: _saving ? null : () => _pickDay(isFirst: true),
|
||||
),
|
||||
AppSwitchRow(
|
||||
title: 'Вторая выплата',
|
||||
value: _secondDay != null,
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (value) => setState(() => _secondDay = value ? 20 : null),
|
||||
),
|
||||
if (_secondDay != null)
|
||||
AppListTile(
|
||||
title: 'Вторая выплата',
|
||||
value: '$_secondDay числа',
|
||||
onTap: _saving ? null : () => _pickDay(isFirst: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_secondDay != null) ...[
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppCard(
|
||||
title: 'Доля первой выплаты',
|
||||
subtitle: '${_firstPercent.round()}% — '
|
||||
'${formatMoney(salary * _firstPercent / 100)} из ${formatMoney(salary)}',
|
||||
child: CupertinoSlider(
|
||||
value: _firstPercent,
|
||||
min: 5,
|
||||
max: 95,
|
||||
divisions: 18,
|
||||
activeColor: AppColors.of(context, AppColors.accent),
|
||||
onChanged: _saving ? null : (v) => setState(() => _firstPercent = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
const AppSectionHeader('Если выплата на выходных'),
|
||||
AppSegmentedControl(
|
||||
labels: WeekendPolicy.values.map((policy) => policy.label).toList(),
|
||||
index: WeekendPolicy.values.indexOf(_policy),
|
||||
onChanged: (index) => setState(() => _policy = WeekendPolicy.values[index]),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s3,
|
||||
AppSpacing.gutter,
|
||||
0,
|
||||
),
|
||||
child: AppText.footnote(_error!, color: AppColors.systemRed),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: AppButton(
|
||||
label: _isEditing ? 'Сохранить' : 'Добавить работу',
|
||||
loading: _saving,
|
||||
onPressed: _submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDay({required bool isFirst}) async {
|
||||
final initial = isFirst ? _firstDay : (_secondDay ?? 20);
|
||||
var picked = initial;
|
||||
|
||||
final result = await showCupertinoModalPopup<int>(
|
||||
context: context,
|
||||
builder: (ctx) => Container(
|
||||
height: 280,
|
||||
color: AppColors.of(ctx, AppColors.groupedSurface),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
itemExtent: 36,
|
||||
scrollController: FixedExtentScrollController(initialItem: initial - 1),
|
||||
onSelectedItemChanged: (index) => picked = index + 1,
|
||||
children: [
|
||||
for (var day = 1; day <= 31; day++) Center(child: AppText.body('$day числа')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s2,
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s3,
|
||||
),
|
||||
child: AppButton(
|
||||
label: 'Готово',
|
||||
onPressed: () => Navigator.of(ctx).pop(picked),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
if (isFirst) {
|
||||
_firstDay = result;
|
||||
} else {
|
||||
_secondDay = result;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showJobFormSheet({
|
||||
required BuildContext context,
|
||||
required JobsController controller,
|
||||
Job? initial,
|
||||
}) async {
|
||||
final saved = await showAppFormSheet<bool>(
|
||||
context: context,
|
||||
builder: (_) => JobFormSheet(
|
||||
initial: initial,
|
||||
onSubmit: ({
|
||||
required name,
|
||||
required salaryAmount,
|
||||
required payDays,
|
||||
required firstPayPercent,
|
||||
required weekendPolicy,
|
||||
}) {
|
||||
if (initial == null) {
|
||||
return controller.create(
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
);
|
||||
}
|
||||
return controller.update(
|
||||
jobId: initial.id,
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (saved == true && context.mounted) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: initial == null ? 'Работа добавлена' : 'Работа обновлена',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:please_pay_me/core/state/async_value.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
class JobsController extends ChangeNotifier {
|
||||
JobsController({required JobRepository jobs}) : _jobs = jobs;
|
||||
|
||||
final JobRepository _jobs;
|
||||
|
||||
AsyncValue<List<Job>> _state = const AsyncValue.loading();
|
||||
bool _mutating = false;
|
||||
|
||||
AsyncValue<List<Job>> get state => _state;
|
||||
bool get isMutating => _mutating;
|
||||
List<Job> get items => _state.valueOrNull ?? const [];
|
||||
|
||||
/// Nearest payday across all jobs — shown on the overview screen.
|
||||
UpcomingPay? get nextPay {
|
||||
final pays = items.expand((job) => job.nextPays).toList()
|
||||
..sort((a, b) => a.date.compareTo(b.date));
|
||||
return pays.isEmpty ? null : pays.first;
|
||||
}
|
||||
|
||||
Future<void> load({bool silent = false}) async {
|
||||
if (!silent) {
|
||||
_state = const AsyncValue.loading();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
_state = AsyncValue.data(await _jobs.list());
|
||||
} on ApiException catch (error) {
|
||||
_state = AsyncValue.error(error.message);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> create({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) {
|
||||
return _mutate(
|
||||
() => _jobs.create(
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> update({
|
||||
required int jobId,
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) {
|
||||
return _mutate(
|
||||
() => _jobs.update(
|
||||
jobId: jobId,
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> delete(int jobId) => _mutate(() => _jobs.delete(jobId));
|
||||
|
||||
Future<String?> _mutate(Future<void> Function() action) async {
|
||||
_mutating = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await action();
|
||||
await load(silent: true);
|
||||
return null;
|
||||
} on ApiException catch (error) {
|
||||
return error.message;
|
||||
} finally {
|
||||
_mutating = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
import 'package:please_pay_me/features/work/job_form_sheet.dart';
|
||||
import 'package:please_pay_me/features/work/jobs_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Jobs and their payday schedule.
|
||||
class WorkScreen extends StatelessWidget {
|
||||
const WorkScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<JobsController>();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||
slivers: [
|
||||
AppLargeNavBar(
|
||||
title: 'Работа',
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => showJobFormSheet(context: context, controller: controller),
|
||||
child: const AppIcon(CupertinoIcons.add_circled, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
CupertinoSliverRefreshControl(onRefresh: () => controller.load(silent: true)),
|
||||
SliverToBoxAdapter(
|
||||
child: controller.state.map(
|
||||
loading: () => AppListSection(
|
||||
children: List.generate(2, (_) => const AppSkeletonRow()),
|
||||
),
|
||||
error: (message) => AppErrorView(message: message, onRetry: controller.load),
|
||||
data: (jobs) => jobs.isEmpty
|
||||
? AppEmptyState(
|
||||
icon: CupertinoIcons.briefcase,
|
||||
title: 'Работа не добавлена',
|
||||
message: 'Укажите оклад и дни выплат — приложение подскажет даты зарплаты.',
|
||||
actionLabel: 'Добавить работу',
|
||||
onAction: () =>
|
||||
showJobFormSheet(context: context, controller: controller),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final job in jobs) ...[
|
||||
_JobCard(job: job, controller: controller),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JobCard extends StatelessWidget {
|
||||
const _JobCard({required this.job, required this.controller});
|
||||
|
||||
final Job job;
|
||||
final JobsController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppListSection(
|
||||
header: job.name,
|
||||
separatorIndent: 60,
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.briefcase_fill,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
title: 'Оклад',
|
||||
value: formatMoney(job.salaryAmount, currency: job.currency),
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.calendar,
|
||||
color: AppColors.systemOrange,
|
||||
),
|
||||
title: 'Дни выплат',
|
||||
value: job.payDays.map((day) => '$day').join(' и '),
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
leading: const AppIconBadge(
|
||||
icon: CupertinoIcons.arrow_left_right,
|
||||
color: AppColors.systemGray,
|
||||
),
|
||||
title: 'Выходные',
|
||||
value: job.weekendPolicy.label,
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
title: 'Настроить',
|
||||
onTap: () => _openActions(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (job.nextPays.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppListSection(
|
||||
header: 'Ближайшие выплаты',
|
||||
children: [
|
||||
for (final pay in job.nextPays)
|
||||
AppListTile(
|
||||
title: formatDay(pay.date),
|
||||
subtitle: '${pay.percent.round()}% оклада · ${pay.scheduledDay} числа',
|
||||
value: formatMoney(pay.amount, currency: job.currency),
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openActions(BuildContext context) async {
|
||||
final index = await showAppActionSheet(
|
||||
context: context,
|
||||
title: job.name,
|
||||
actions: const [
|
||||
AppActionSheetAction(label: 'Редактировать', isDefault: true),
|
||||
AppActionSheetAction(label: 'Удалить', destructive: true),
|
||||
],
|
||||
);
|
||||
|
||||
if (index == null || !context.mounted) return;
|
||||
|
||||
if (index == 0) {
|
||||
await showJobFormSheet(context: context, controller: controller, initial: job);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showAppAlert(
|
||||
context: context,
|
||||
title: 'Удалить «${job.name}»?',
|
||||
message: 'График выплат тоже будет удалён.',
|
||||
confirmLabel: 'Удалить',
|
||||
cancelLabel: 'Отмена',
|
||||
destructive: true,
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
final error = await controller.delete(job.id);
|
||||
if (context.mounted) {
|
||||
await showAppToast(context, message: error ?? 'Работа удалена');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user