feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+331
View File
@@ -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()),
],
),
),
);
}
}
+332
View File
@@ -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();
}
}
}
+166
View File
@@ -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 ?? 'Работа удалена');
}
}
}