feat(proj): init
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:please_pay_me/app/home_tabs.dart';
|
||||
import 'package:please_pay_me/features/auth/login_screen.dart';
|
||||
import 'package:please_pay_me/features/auth/session_controller.dart';
|
||||
import 'package:please_pay_me/features/splash/splash_screen.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/core/branding/app_brand.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class PleasePayMeApp extends StatelessWidget {
|
||||
PleasePayMeApp({super.key, required this.session, ThemeController? theme})
|
||||
: theme = theme ?? ThemeController(store: MemoryThemeStore());
|
||||
|
||||
final SessionController session;
|
||||
final ThemeController theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<SessionController>.value(value: session),
|
||||
ChangeNotifierProvider<ThemeController>.value(value: theme),
|
||||
],
|
||||
child: Consumer<ThemeController>(
|
||||
builder: (context, theme, _) {
|
||||
final platform = MediaQuery.platformBrightnessOf(context);
|
||||
final brightness = theme.resolve(platform);
|
||||
|
||||
return CupertinoApp(
|
||||
title: AppBrand.name,
|
||||
theme: brightness == Brightness.dark ? buildDarkTheme() : buildLightTheme(),
|
||||
locale: const Locale('ru'),
|
||||
supportedLocales: const [Locale('ru'), Locale('en')],
|
||||
localizationsDelegates: const [
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
builder: (context, child) {
|
||||
final overlay = systemUiOverlayFor(brightness);
|
||||
SystemChrome.setSystemUIOverlayStyle(overlay);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: overlay,
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(platformBrightness: brightness),
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
},
|
||||
home: const _SessionGate(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionGate extends StatelessWidget {
|
||||
const _SessionGate();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
return switch (session.status) {
|
||||
SessionStatus.restoring => const SplashScreen(),
|
||||
SessionStatus.signedOut => const LoginScreen(),
|
||||
SessionStatus.signedIn => AppDataScope(
|
||||
key: ValueKey(session.sessionKey),
|
||||
session: session,
|
||||
child: const HomeTabs(),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature controllers bound to the current session. Rebuilt from scratch when
|
||||
/// the session changes, so no stale data survives a re-login.
|
||||
class AppDataScope extends StatelessWidget {
|
||||
const AppDataScope({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final SessionController session;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => BudgetsController(
|
||||
budgets: session.budgets,
|
||||
expenses: session.expenses,
|
||||
)..load(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => JournalController(expenses: session.expenses)..load(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => JobsController(jobs: session.jobs)..load(),
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/features/budgets/budgets_screen.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_screen.dart';
|
||||
import 'package:please_pay_me/features/overview/overview_screen.dart';
|
||||
import 'package:please_pay_me/features/profile/profile_screen.dart';
|
||||
import 'package:please_pay_me/features/work/work_screen.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
|
||||
/// Root tab bar. Each tab keeps its own navigator so modal sheets and alerts
|
||||
/// stay inside the tab, as iOS expects.
|
||||
class HomeTabs extends StatelessWidget {
|
||||
const HomeTabs({super.key});
|
||||
|
||||
static const _tabs = [
|
||||
AppTabItem(
|
||||
icon: CupertinoIcons.chart_pie,
|
||||
activeIcon: CupertinoIcons.chart_pie_fill,
|
||||
label: 'Обзор',
|
||||
),
|
||||
AppTabItem(
|
||||
icon: CupertinoIcons.list_bullet,
|
||||
label: 'Журнал',
|
||||
),
|
||||
AppTabItem(
|
||||
icon: CupertinoIcons.money_rubl_circle,
|
||||
activeIcon: CupertinoIcons.money_rubl_circle_fill,
|
||||
label: 'Бюджеты',
|
||||
),
|
||||
AppTabItem(
|
||||
icon: CupertinoIcons.briefcase,
|
||||
activeIcon: CupertinoIcons.briefcase_fill,
|
||||
label: 'Работа',
|
||||
),
|
||||
AppTabItem(
|
||||
icon: CupertinoIcons.person,
|
||||
activeIcon: CupertinoIcons.person_fill,
|
||||
label: 'Профиль',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoTabScaffold(
|
||||
tabBar: AppTabBar(items: _tabs, currentIndex: 0, onTap: (_) {}),
|
||||
tabBuilder: (context, index) => CupertinoTabView(
|
||||
builder: (context) => switch (index) {
|
||||
0 => const OverviewScreen(),
|
||||
1 => const JournalScreen(),
|
||||
2 => const BudgetsScreen(),
|
||||
3 => const WorkScreen(),
|
||||
_ => const ProfileScreen(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/// User-facing product name on the home screen and in the UI.
|
||||
abstract final class AppBrand {
|
||||
static const name = 'Дожить до ЗП';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:please_pay_me/core/config/env_file.dart';
|
||||
|
||||
/// Runtime configuration.
|
||||
///
|
||||
/// Values are resolved in this order:
|
||||
/// 1. `--dart-define=PPM_*` / `--dart-define-from-file=.env` (CI and `run.ps1`)
|
||||
/// 2. key/value map parsed from `mobile/.env` (tests and explicit loaders)
|
||||
/// 3. production cabinet if nothing is set
|
||||
class AppConfig {
|
||||
static const productionOrigin = 'https://please-pay-me.ru';
|
||||
|
||||
const AppConfig({
|
||||
required this.apiBaseUrl,
|
||||
required this.webCabinetUrl,
|
||||
this.demoMode = false,
|
||||
});
|
||||
|
||||
factory AppConfig.fromEnvironment({Map<String, String> file = const {}}) {
|
||||
const definedApi = String.fromEnvironment('PPM_API_BASE_URL');
|
||||
const definedWeb = String.fromEnvironment('PPM_WEB_URL');
|
||||
const definedDemo = String.fromEnvironment('PPM_DEMO');
|
||||
|
||||
return AppConfig.fromMap({
|
||||
...file,
|
||||
if (definedApi.isNotEmpty) 'PPM_API_BASE_URL': definedApi,
|
||||
if (definedWeb.isNotEmpty) 'PPM_WEB_URL': definedWeb,
|
||||
if (definedDemo.isNotEmpty) 'PPM_DEMO': definedDemo,
|
||||
});
|
||||
}
|
||||
|
||||
factory AppConfig.fromMap(Map<String, String> values) {
|
||||
final apiBase = normalizeUrl(values['PPM_API_BASE_URL'] ?? '');
|
||||
final webUrl = normalizeUrl(values['PPM_WEB_URL'] ?? '');
|
||||
final demoForced = parseEnvFlag(values['PPM_DEMO']);
|
||||
final resolvedApi = apiBase.isEmpty ? productionOrigin : apiBase;
|
||||
final resolvedWeb = webUrl.isEmpty ? resolvedApi : webUrl;
|
||||
|
||||
return AppConfig(
|
||||
apiBaseUrl: resolvedApi,
|
||||
webCabinetUrl: resolvedWeb,
|
||||
demoMode: demoForced,
|
||||
);
|
||||
}
|
||||
|
||||
static const demo = AppConfig(apiBaseUrl: '', webCabinetUrl: '', demoMode: true);
|
||||
|
||||
final String apiBaseUrl;
|
||||
final String webCabinetUrl;
|
||||
final bool demoMode;
|
||||
|
||||
AppConfig copyWith({String? apiBaseUrl, String? webCabinetUrl, bool? demoMode}) {
|
||||
return AppConfig(
|
||||
apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl,
|
||||
webCabinetUrl: webCabinetUrl ?? this.webCabinetUrl,
|
||||
demoMode: demoMode ?? this.demoMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Minimal `.env` parser (KEY=VALUE, `#` comments, optional quotes).
|
||||
///
|
||||
/// Kept tiny and dependency-free so config loading is easy to test and does
|
||||
/// not pull `flutter_dotenv` into the production graph.
|
||||
Map<String, String> parseEnvFile(String source) {
|
||||
final values = <String, String>{};
|
||||
|
||||
for (final raw in source.split(RegExp(r'\r?\n'))) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty || line.startsWith('#')) continue;
|
||||
|
||||
final separator = line.indexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
|
||||
final key = line.substring(0, separator).trim();
|
||||
if (key.isEmpty) continue;
|
||||
|
||||
var value = line.substring(separator + 1).trim();
|
||||
if (value.length >= 2) {
|
||||
final quote = value[0];
|
||||
if ((quote == '"' || quote == "'") && value.endsWith(quote)) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
String normalizeUrl(String url) => url.trim().replaceAll(RegExp(r'/$'), '');
|
||||
|
||||
bool parseEnvFlag(String? raw, {bool fallback = false}) {
|
||||
if (raw == null || raw.trim().isEmpty) return fallback;
|
||||
return switch (raw.trim().toLowerCase()) {
|
||||
'1' || 'true' || 'yes' || 'on' => true,
|
||||
'0' || 'false' || 'no' || 'off' => false,
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'env_loader_stub.dart' if (dart.library.io) 'env_loader_io.dart' as impl;
|
||||
|
||||
Future<Map<String, String>> loadEnvFile() => impl.loadEnvFileImpl();
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:please_pay_me/core/config/env_file.dart';
|
||||
|
||||
/// Reads `mobile/.env` when the process cwd is the package or the repo root.
|
||||
/// Dart-defines from `run.ps1` still win in [AppConfig.fromEnvironment].
|
||||
Future<Map<String, String>> loadEnvFileImpl() async {
|
||||
for (final path in const ['.env', 'mobile/.env']) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
return parseEnvFile(await file.readAsString());
|
||||
}
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Future<Map<String, String>> loadEnvFileImpl() async => const {};
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// `1 234,50 ₽` — same shape as the web cabinet.
|
||||
String formatMoney(double amount, {String currency = 'RUB', bool compact = false}) {
|
||||
final symbol = switch (currency) {
|
||||
'RUB' => '₽',
|
||||
'USD' => r'$',
|
||||
'EUR' => '€',
|
||||
_ => currency,
|
||||
};
|
||||
|
||||
final formatter = compact
|
||||
? NumberFormat.decimalPattern('ru')
|
||||
: NumberFormat('#,##0.00', 'ru');
|
||||
final value = compact ? amount.round() : amount;
|
||||
|
||||
return '${formatter.format(value)} $symbol'.replaceAll('\u00A0', ' ');
|
||||
}
|
||||
|
||||
String formatSignedMoney(double amount, {String currency = 'RUB'}) {
|
||||
final sign = amount < 0 ? '+' : '−';
|
||||
return '$sign${formatMoney(amount.abs(), currency: currency)}';
|
||||
}
|
||||
|
||||
String formatDay(DateTime date) => DateFormat('d MMMM', 'ru').format(date);
|
||||
|
||||
String formatShortDate(DateTime date) => DateFormat('dd.MM.yyyy').format(date);
|
||||
|
||||
String formatWeekday(DateTime date) => DateFormat('EEEE', 'ru').format(date);
|
||||
|
||||
/// `Сегодня` / `Вчера` / `12 сентября` — headers of the journal.
|
||||
String formatRelativeDay(DateTime date, {DateTime? now}) {
|
||||
final today = _dayOf(now ?? DateTime.now());
|
||||
final day = _dayOf(date);
|
||||
final diff = today.difference(day).inDays;
|
||||
|
||||
return switch (diff) {
|
||||
0 => 'Сегодня',
|
||||
1 => 'Вчера',
|
||||
_ => formatDay(day),
|
||||
};
|
||||
}
|
||||
|
||||
/// `осталось 5 дней` — Russian plural rules.
|
||||
String formatDaysLeft(int days) {
|
||||
if (days <= 0) return 'период завершён';
|
||||
return 'осталось ${plural(days, 'день', 'дня', 'дней')}';
|
||||
}
|
||||
|
||||
String plural(int count, String one, String few, String many) {
|
||||
final mod100 = count % 100;
|
||||
final mod10 = count % 10;
|
||||
|
||||
if (mod100 >= 11 && mod100 <= 14) return '$count $many';
|
||||
if (mod10 == 1) return '$count $one';
|
||||
if (mod10 >= 2 && mod10 <= 4) return '$count $few';
|
||||
return '$count $many';
|
||||
}
|
||||
|
||||
DateTime _dayOf(DateTime value) => DateTime(value.year, value.month, value.day);
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/config/app_config.dart';
|
||||
import 'package:please_pay_me/core/config/env_file.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
abstract final class LegalLinks {
|
||||
static const offer = '/legal/offer';
|
||||
static const privacy = '/legal/privacy';
|
||||
static const consent = '/legal/consent';
|
||||
static const cookies = '/legal/cookies';
|
||||
|
||||
static Uri resolve(String cabinetUrl, String path) {
|
||||
final base = cabinetUrl.isEmpty ? AppConfig.productionOrigin : cabinetUrl;
|
||||
return Uri.parse('${normalizeUrl(base)}$path');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openLegalDocument(BuildContext context, Uri uri) async {
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened && context.mounted) {
|
||||
await showAppToast(context, message: 'Не удалось открыть документ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Minimal async state container so screens can pattern-match over
|
||||
/// loading / data / error instead of juggling three nullable fields.
|
||||
sealed class AsyncValue<T> {
|
||||
const AsyncValue();
|
||||
|
||||
const factory AsyncValue.loading() = AsyncLoading<T>;
|
||||
const factory AsyncValue.data(T value) = AsyncData<T>;
|
||||
const factory AsyncValue.error(String message) = AsyncError<T>;
|
||||
|
||||
T? get valueOrNull => this is AsyncData<T> ? (this as AsyncData<T>).value : null;
|
||||
|
||||
bool get isLoading => this is AsyncLoading<T>;
|
||||
|
||||
R map<R>({
|
||||
required R Function() loading,
|
||||
required R Function(T value) data,
|
||||
required R Function(String message) error,
|
||||
}) {
|
||||
return switch (this) {
|
||||
AsyncLoading<T>() => loading(),
|
||||
AsyncData<T>(value: final v) => data(v),
|
||||
AsyncError<T>(message: final m) => error(m),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final class AsyncLoading<T> extends AsyncValue<T> {
|
||||
const AsyncLoading();
|
||||
}
|
||||
|
||||
final class AsyncData<T> extends AsyncValue<T> {
|
||||
const AsyncData(this.value);
|
||||
|
||||
final T value;
|
||||
}
|
||||
|
||||
final class AsyncError<T> extends AsyncValue<T> {
|
||||
const AsyncError(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persisted session: JWT, API address and the cached user profile.
|
||||
abstract interface class SessionStorage {
|
||||
Future<Map<String, String?>> readAll();
|
||||
|
||||
Future<void> write({
|
||||
required String token,
|
||||
required String baseUrl,
|
||||
required String user,
|
||||
});
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class PrefsSessionStorage implements SessionStorage {
|
||||
const PrefsSessionStorage();
|
||||
|
||||
static const _tokenKey = 'ppm_token';
|
||||
static const _baseUrlKey = 'ppm_base_url';
|
||||
static const _userKey = 'ppm_user';
|
||||
|
||||
@override
|
||||
Future<Map<String, String?>> readAll() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return {
|
||||
'token': prefs.getString(_tokenKey),
|
||||
'baseUrl': prefs.getString(_baseUrlKey),
|
||||
'user': prefs.getString(_userKey),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String token,
|
||||
required String baseUrl,
|
||||
required String user,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, token);
|
||||
await prefs.setString(_baseUrlKey, baseUrl);
|
||||
await prefs.setString(_userKey, user);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_baseUrlKey);
|
||||
await prefs.remove(_userKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Used by tests and previews — no platform channels involved.
|
||||
class InMemorySessionStorage implements SessionStorage {
|
||||
InMemorySessionStorage([Map<String, String?>? initial])
|
||||
: _values = {...?initial};
|
||||
|
||||
final Map<String, String?> _values;
|
||||
|
||||
@override
|
||||
Future<Map<String, String?>> readAll() async => Map.of(_values);
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String token,
|
||||
required String baseUrl,
|
||||
required String user,
|
||||
}) async {
|
||||
_values
|
||||
..['token'] = token
|
||||
..['baseUrl'] = baseUrl
|
||||
..['user'] = user;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _values.clear();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message, {this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
bool get isUnauthorized => statusCode == 401;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Thin JSON transport over the PleasePayMe REST API.
|
||||
///
|
||||
/// Keeps auth concerns out of repositories: the token is supplied lazily so a
|
||||
/// re-login does not require rebuilding the whole object graph.
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
required String baseUrl,
|
||||
required String? Function() tokenProvider,
|
||||
http.Client? httpClient,
|
||||
this.onUnauthorized,
|
||||
this.timeout = const Duration(seconds: 15),
|
||||
}) : _baseUrl = baseUrl.replaceAll(RegExp(r'/$'), ''),
|
||||
_tokenProvider = tokenProvider,
|
||||
_http = httpClient ?? http.Client();
|
||||
|
||||
final String _baseUrl;
|
||||
final String? Function() _tokenProvider;
|
||||
final http.Client _http;
|
||||
final void Function()? onUnauthorized;
|
||||
final Duration timeout;
|
||||
|
||||
Future<Map<String, dynamic>> getJson(String path, {Map<String, String>? query}) async {
|
||||
return asMap(await _send('GET', path, query: query));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postJson(
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? query,
|
||||
}) async {
|
||||
return asMap(await _send('POST', path, body: body, query: query));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> putJson(String path, {Map<String, dynamic>? body}) async {
|
||||
return asMap(await _send('PUT', path, body: body));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> patchJson(String path, {Map<String, dynamic>? body}) async {
|
||||
return asMap(await _send('PATCH', path, body: body));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteJson(String path, {Map<String, String>? query}) async {
|
||||
return asMap(await _send('DELETE', path, query: query));
|
||||
}
|
||||
|
||||
Future<Object?> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? query,
|
||||
}) async {
|
||||
final uri = Uri.parse('$_baseUrl$path').replace(
|
||||
queryParameters: query?.isEmpty ?? true ? null : query,
|
||||
);
|
||||
|
||||
final request = http.Request(method, uri);
|
||||
request.headers['Accept'] = 'application/json';
|
||||
final token = _tokenProvider();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
|
||||
late final http.Response response;
|
||||
try {
|
||||
final streamed = await _http.send(request).timeout(timeout);
|
||||
response = await http.Response.fromStream(streamed);
|
||||
} on Exception catch (error) {
|
||||
throw ApiException('Нет связи с сервером: $error');
|
||||
}
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
onUnauthorized?.call();
|
||||
throw const ApiException('Сессия истекла, войдите заново', statusCode: 401);
|
||||
}
|
||||
|
||||
final raw = utf8.decode(response.bodyBytes);
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
throw ApiException(_extractError(raw, response.statusCode), statusCode: response.statusCode);
|
||||
}
|
||||
|
||||
if (response.statusCode == 204 || raw.trim().isEmpty) return null;
|
||||
|
||||
try {
|
||||
return jsonDecode(raw);
|
||||
} on FormatException {
|
||||
throw ApiException('Сервер вернул не JSON (HTTP ${response.statusCode})');
|
||||
}
|
||||
}
|
||||
|
||||
String _extractError(String raw, int statusCode) {
|
||||
try {
|
||||
final parsed = asMap(jsonDecode(raw));
|
||||
final detail = asStringOrNull(parsed['detail']) ?? asStringOrNull(parsed['title']);
|
||||
if (detail != null) return detail;
|
||||
} on FormatException {
|
||||
// Fall through to the raw payload.
|
||||
}
|
||||
return raw.trim().isEmpty ? 'Ошибка запроса (HTTP $statusCode)' : raw.trim();
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
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/budget.dart';
|
||||
import 'package:please_pay_me/data/models/expense.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
/// In-memory backend used for Widgetbook previews, widget tests and for
|
||||
/// running the app without a server (`PPM_DEMO=true`).
|
||||
///
|
||||
/// Mirrors the envelope math of `IBudgetService` closely enough that screens
|
||||
/// behave the same as against the real API.
|
||||
class DemoBackend {
|
||||
DemoBackend({DateTime? today, bool seed = true})
|
||||
: _today = _dayOf(today ?? DateTime.now()) {
|
||||
if (seed) _seed();
|
||||
}
|
||||
|
||||
/// Backend without any data — used for empty-state previews and tests.
|
||||
factory DemoBackend.empty({DateTime? today}) =>
|
||||
DemoBackend(today: today, seed: false);
|
||||
|
||||
final DateTime _today;
|
||||
final List<Budget> _budgets = [];
|
||||
final List<Expense> _expenses = [];
|
||||
final List<Job> _jobs = [];
|
||||
|
||||
int _selectedBudgetId = 1;
|
||||
int _nextExpenseId = 100;
|
||||
int _nextBudgetId = 3;
|
||||
int _nextJobId = 2;
|
||||
|
||||
static const user = AuthUser(
|
||||
userId: 1,
|
||||
firstName: 'Владимир',
|
||||
username: 'pleasepayme',
|
||||
);
|
||||
|
||||
BudgetRepository get budgets => _DemoBudgetRepository(this);
|
||||
|
||||
ExpenseRepository get expenses => _DemoExpenseRepository(this);
|
||||
|
||||
JobRepository get jobs => _DemoJobRepository(this);
|
||||
|
||||
UserRepository get users => _DemoUserRepository();
|
||||
|
||||
void _seed() {
|
||||
_budgets.addAll([
|
||||
Budget(
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'До аванса',
|
||||
totalAmount: 42000,
|
||||
startDate: _today.subtract(const Duration(days: 6)),
|
||||
endDate: _today.add(const Duration(days: 8)),
|
||||
currency: 'RUB',
|
||||
isActive: true,
|
||||
),
|
||||
Budget(
|
||||
id: 2,
|
||||
userId: 1,
|
||||
name: 'Отпуск',
|
||||
totalAmount: 90000,
|
||||
startDate: _today.subtract(const Duration(days: 40)),
|
||||
endDate: _today.subtract(const Duration(days: 5)),
|
||||
currency: 'RUB',
|
||||
isActive: false,
|
||||
),
|
||||
]);
|
||||
|
||||
_expenses.addAll([
|
||||
Expense(id: 1, budgetId: 1, amount: 1840, note: 'Продукты', spentAt: _today),
|
||||
Expense(id: 2, budgetId: 1, amount: 250, note: 'Кофе', spentAt: _today),
|
||||
Expense(
|
||||
id: 3,
|
||||
budgetId: 1,
|
||||
amount: 640,
|
||||
note: 'Такси',
|
||||
spentAt: _today.subtract(const Duration(days: 1)),
|
||||
),
|
||||
Expense(
|
||||
id: 4,
|
||||
budgetId: 1,
|
||||
amount: 3200,
|
||||
note: 'Аптека',
|
||||
spentAt: _today.subtract(const Duration(days: 2)),
|
||||
),
|
||||
Expense(
|
||||
id: 5,
|
||||
budgetId: 2,
|
||||
amount: 15000,
|
||||
note: 'Билеты',
|
||||
spentAt: _today.subtract(const Duration(days: 20)),
|
||||
),
|
||||
]);
|
||||
|
||||
_jobs.add(
|
||||
Job(
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Основная работа',
|
||||
salaryAmount: 180000,
|
||||
currency: 'RUB',
|
||||
payDays: const [5, 20],
|
||||
firstPayPercent: 40,
|
||||
weekendPolicy: WeekendPolicy.beforeWeekend,
|
||||
isActive: true,
|
||||
nextPays: [
|
||||
UpcomingPay(
|
||||
date: _today.add(const Duration(days: 8)),
|
||||
scheduledDay: 20,
|
||||
percent: 60,
|
||||
amount: 108000,
|
||||
),
|
||||
UpcomingPay(
|
||||
date: _today.add(const Duration(days: 23)),
|
||||
scheduledDay: 5,
|
||||
percent: 40,
|
||||
amount: 72000,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Budget _budgetById(int? id) {
|
||||
if (_budgets.isEmpty) {
|
||||
throw const ApiException('Сначала создайте бюджет');
|
||||
}
|
||||
final budgetId = id ?? _selectedBudgetId;
|
||||
return _budgets.firstWhere(
|
||||
(budget) => budget.id == budgetId,
|
||||
orElse: () => _budgets.first,
|
||||
);
|
||||
}
|
||||
|
||||
BudgetStatus statusOf(Budget budget) {
|
||||
final spent = _expenses
|
||||
.where((expense) => expense.budgetId == budget.id)
|
||||
.fold<double>(0, (sum, expense) => sum + expense.amount);
|
||||
final spentToday = _expenses
|
||||
.where((e) => e.budgetId == budget.id && _dayOf(e.spentAt) == _today)
|
||||
.fold<double>(0, (sum, expense) => sum + expense.amount);
|
||||
|
||||
final daysLeft = budget.endDate.difference(_today).inDays + 1;
|
||||
final safeDays = daysLeft < 1 ? 0 : daysLeft;
|
||||
final remaining = budget.totalAmount - spent;
|
||||
final dailyLimit = safeDays == 0 ? 0.0 : (remaining <= 0 ? 0.0 : remaining / safeDays);
|
||||
|
||||
return BudgetStatus(
|
||||
budget: budget,
|
||||
today: _today,
|
||||
daysLeft: safeDays,
|
||||
totalSpent: spent,
|
||||
remaining: remaining,
|
||||
dailyLimit: dailyLimit,
|
||||
spentToday: spentToday,
|
||||
remainingToday: dailyLimit - spentToday,
|
||||
isOverDaily: spentToday > dailyLimit,
|
||||
isOverBudget: remaining < 0,
|
||||
isExpired: safeDays == 0,
|
||||
selected: budget.id == _selectedBudgetId,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _dayOf(DateTime value) => DateTime(value.year, value.month, value.day);
|
||||
}
|
||||
|
||||
class _DemoBudgetRepository implements BudgetRepository {
|
||||
const _DemoBudgetRepository(this._backend);
|
||||
|
||||
final DemoBackend _backend;
|
||||
|
||||
@override
|
||||
Future<List<BudgetStatus>> list() async {
|
||||
return _backend._budgets.map(_backend.statusOf).toList()
|
||||
..sort((a, b) {
|
||||
if (a.selected != b.selected) return a.selected ? -1 : 1;
|
||||
return b.budget.endDate.compareTo(a.budget.endDate);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> status({int? budgetId}) async {
|
||||
return _backend.statusOf(_backend._budgetById(budgetId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> create({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime endDate,
|
||||
DateTime? startDate,
|
||||
}) async {
|
||||
final budget = Budget(
|
||||
id: _backend._nextBudgetId++,
|
||||
userId: 1,
|
||||
name: name,
|
||||
totalAmount: totalAmount,
|
||||
startDate: startDate ?? _backend._today,
|
||||
endDate: endDate,
|
||||
currency: 'RUB',
|
||||
isActive: true,
|
||||
);
|
||||
_backend._budgets.add(budget);
|
||||
_backend._selectedBudgetId = budget.id;
|
||||
return _backend.statusOf(budget);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> update({
|
||||
required int budgetId,
|
||||
String? name,
|
||||
double? totalAmount,
|
||||
DateTime? endDate,
|
||||
DateTime? startDate,
|
||||
bool resetExpenses = false,
|
||||
}) async {
|
||||
final index = _backend._budgets.indexWhere((budget) => budget.id == budgetId);
|
||||
final current = _backend._budgets[index];
|
||||
final updated = Budget(
|
||||
id: current.id,
|
||||
userId: current.userId,
|
||||
name: name ?? current.name,
|
||||
totalAmount: totalAmount ?? current.totalAmount,
|
||||
startDate: startDate ?? current.startDate,
|
||||
endDate: endDate ?? current.endDate,
|
||||
currency: current.currency,
|
||||
isActive: current.isActive,
|
||||
);
|
||||
_backend._budgets[index] = updated;
|
||||
if (resetExpenses) {
|
||||
_backend._expenses.removeWhere((expense) => expense.budgetId == budgetId);
|
||||
}
|
||||
return _backend.statusOf(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> select(int budgetId) async {
|
||||
_backend._selectedBudgetId = budgetId;
|
||||
return _backend.statusOf(_backend._budgetById(budgetId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> setActive(int budgetId, {required bool isActive}) async {
|
||||
final index = _backend._budgets.indexWhere((budget) => budget.id == budgetId);
|
||||
final current = _backend._budgets[index];
|
||||
final updated = Budget(
|
||||
id: current.id,
|
||||
userId: current.userId,
|
||||
name: current.name,
|
||||
totalAmount: current.totalAmount,
|
||||
startDate: current.startDate,
|
||||
endDate: current.endDate,
|
||||
currency: current.currency,
|
||||
isActive: isActive,
|
||||
);
|
||||
_backend._budgets[index] = updated;
|
||||
return _backend.statusOf(updated);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int budgetId) async {
|
||||
_backend._budgets.removeWhere((budget) => budget.id == budgetId);
|
||||
_backend._expenses.removeWhere((expense) => expense.budgetId == budgetId);
|
||||
if (_backend._selectedBudgetId == budgetId && _backend._budgets.isNotEmpty) {
|
||||
_backend._selectedBudgetId = _backend._budgets.first.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DemoExpenseRepository implements ExpenseRepository {
|
||||
const _DemoExpenseRepository(this._backend);
|
||||
|
||||
final DemoBackend _backend;
|
||||
|
||||
@override
|
||||
Future<ExpensesPage> page({
|
||||
required int page,
|
||||
int pageSize = 20,
|
||||
int? budgetId,
|
||||
bool all = false,
|
||||
}) async {
|
||||
final scope = all
|
||||
? _backend._expenses
|
||||
: _backend._expenses
|
||||
.where((e) => e.budgetId == (budgetId ?? _backend._selectedBudgetId));
|
||||
|
||||
final sorted = scope.toList()
|
||||
..sort((a, b) {
|
||||
final byDate = b.spentAt.compareTo(a.spentAt);
|
||||
return byDate != 0 ? byDate : b.id.compareTo(a.id);
|
||||
});
|
||||
|
||||
final from = (page - 1) * pageSize;
|
||||
final items = from >= sorted.length
|
||||
? <Expense>[]
|
||||
: sorted.sublist(from, (from + pageSize).clamp(0, sorted.length));
|
||||
|
||||
return ExpensesPage(
|
||||
page: page,
|
||||
totalPages: sorted.isEmpty ? 1 : (sorted.length / pageSize).ceil(),
|
||||
totalCount: sorted.length,
|
||||
pageSize: pageSize,
|
||||
totalSum: sorted.fold<double>(0, (sum, expense) => sum + expense.amount),
|
||||
budgetId: all ? null : (budgetId ?? _backend._selectedBudgetId),
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> create({
|
||||
required double amount,
|
||||
String? note,
|
||||
DateTime? spentAt,
|
||||
int? budgetId,
|
||||
}) async {
|
||||
final budget = _backend._budgetById(budgetId);
|
||||
_backend._expenses.add(
|
||||
Expense(
|
||||
id: _backend._nextExpenseId++,
|
||||
budgetId: budget.id,
|
||||
amount: amount,
|
||||
note: note,
|
||||
spentAt: spentAt ?? _backend._today,
|
||||
),
|
||||
);
|
||||
return _backend.statusOf(budget);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> undoLast({int? budgetId}) async {
|
||||
final budget = _backend._budgetById(budgetId);
|
||||
final scoped = _backend._expenses.where((e) => e.budgetId == budget.id).toList();
|
||||
if (scoped.isEmpty) return 0;
|
||||
|
||||
scoped.sort((a, b) => b.id.compareTo(a.id));
|
||||
final last = scoped.first;
|
||||
_backend._expenses.removeWhere((expense) => expense.id == last.id);
|
||||
return last.amount;
|
||||
}
|
||||
}
|
||||
|
||||
class _DemoJobRepository implements JobRepository {
|
||||
const _DemoJobRepository(this._backend);
|
||||
|
||||
final DemoBackend _backend;
|
||||
|
||||
@override
|
||||
Future<List<Job>> list() async => List.unmodifiable(_backend._jobs);
|
||||
|
||||
@override
|
||||
Future<Job> create({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) async {
|
||||
final job = Job(
|
||||
id: _backend._nextJobId++,
|
||||
userId: 1,
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
currency: 'RUB',
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
isActive: true,
|
||||
nextPays: const [],
|
||||
);
|
||||
_backend._jobs.add(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Job> update({
|
||||
required int jobId,
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
bool isActive = true,
|
||||
}) async {
|
||||
final index = _backend._jobs.indexWhere((job) => job.id == jobId);
|
||||
final current = _backend._jobs[index];
|
||||
final updated = Job(
|
||||
id: current.id,
|
||||
userId: current.userId,
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
currency: current.currency,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
isActive: isActive,
|
||||
nextPays: current.nextPays,
|
||||
);
|
||||
_backend._jobs[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int jobId) async {
|
||||
_backend._jobs.removeWhere((job) => job.id == jobId);
|
||||
}
|
||||
}
|
||||
|
||||
class _DemoUserRepository implements UserRepository {
|
||||
@override
|
||||
Future<AuthUser> me() async => DemoBackend.user;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class AuthUser {
|
||||
const AuthUser({
|
||||
required this.userId,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.username,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> json) {
|
||||
return AuthUser(
|
||||
userId: asInt(json['user_id']),
|
||||
firstName: asStringOrNull(json['first_name']),
|
||||
lastName: asStringOrNull(json['last_name']),
|
||||
username: asStringOrNull(json['username']),
|
||||
photoUrl: asStringOrNull(json['photo_url']),
|
||||
);
|
||||
}
|
||||
|
||||
static AuthUser? tryDecode(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return AuthUser.fromJson(asMap(jsonDecode(raw)));
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final int userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? username;
|
||||
final String? photoUrl;
|
||||
|
||||
String get displayName {
|
||||
final full = [firstName, lastName].whereType<String>().join(' ').trim();
|
||||
if (full.isNotEmpty) return full;
|
||||
if (username != null) return '@$username';
|
||||
return 'Пользователь $userId';
|
||||
}
|
||||
|
||||
String get handle => username != null ? '@$username' : 'id $userId';
|
||||
|
||||
String get initials {
|
||||
final source = firstName?.trim().isNotEmpty == true
|
||||
? firstName!.trim()
|
||||
: username?.trim() ?? '';
|
||||
if (source.isEmpty) return '';
|
||||
return source.substring(0, 1);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'user_id': userId,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'username': username,
|
||||
'photo_url': photoUrl,
|
||||
};
|
||||
|
||||
String encode() => jsonEncode(toJson());
|
||||
}
|
||||
|
||||
class AuthSession {
|
||||
const AuthSession({required this.accessToken, required this.user});
|
||||
|
||||
factory AuthSession.fromJson(Map<String, dynamic> json) {
|
||||
return AuthSession(
|
||||
accessToken: asString(json['access_token']),
|
||||
user: AuthUser.fromJson(asMap(json['user'])),
|
||||
);
|
||||
}
|
||||
|
||||
final String accessToken;
|
||||
final AuthUser user;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class Budget {
|
||||
const Budget({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.totalAmount,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.currency,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory Budget.fromJson(Map<String, dynamic> json) {
|
||||
return Budget(
|
||||
id: asInt(json['id']),
|
||||
userId: asInt(json['user_id']),
|
||||
name: asString(json['name']),
|
||||
totalAmount: asDouble(json['total_amount']),
|
||||
startDate: asDate(json['start_date']),
|
||||
endDate: asDate(json['end_date']),
|
||||
currency: asString(json['currency'], fallback: 'RUB'),
|
||||
isActive: asBool(json['is_active']),
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final int userId;
|
||||
final String name;
|
||||
final double totalAmount;
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
final String currency;
|
||||
final bool isActive;
|
||||
}
|
||||
|
||||
/// Budget plus the server-computed daily envelope math.
|
||||
class BudgetStatus {
|
||||
const BudgetStatus({
|
||||
required this.budget,
|
||||
required this.today,
|
||||
required this.daysLeft,
|
||||
required this.totalSpent,
|
||||
required this.remaining,
|
||||
required this.dailyLimit,
|
||||
required this.spentToday,
|
||||
required this.remainingToday,
|
||||
required this.isOverDaily,
|
||||
required this.isOverBudget,
|
||||
required this.isExpired,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
factory BudgetStatus.fromJson(Map<String, dynamic> json) {
|
||||
return BudgetStatus(
|
||||
budget: Budget.fromJson(asMap(json['budget'])),
|
||||
today: asDate(json['today']),
|
||||
daysLeft: asInt(json['days_left']),
|
||||
totalSpent: asDouble(json['total_spent']),
|
||||
remaining: asDouble(json['remaining']),
|
||||
dailyLimit: asDouble(json['daily_limit']),
|
||||
spentToday: asDouble(json['spent_today']),
|
||||
remainingToday: asDouble(json['remaining_today']),
|
||||
isOverDaily: asBool(json['is_over_daily']),
|
||||
isOverBudget: asBool(json['is_over_budget']),
|
||||
isExpired: asBool(json['is_expired']),
|
||||
selected: asBool(json['selected']),
|
||||
);
|
||||
}
|
||||
|
||||
final Budget budget;
|
||||
final DateTime today;
|
||||
final int daysLeft;
|
||||
final double totalSpent;
|
||||
final double remaining;
|
||||
final double dailyLimit;
|
||||
final double spentToday;
|
||||
final double remainingToday;
|
||||
final bool isOverDaily;
|
||||
final bool isOverBudget;
|
||||
final bool isExpired;
|
||||
final bool selected;
|
||||
|
||||
/// 0..1 — share of the budget already spent.
|
||||
double get spentProgress {
|
||||
if (budget.totalAmount <= 0) return 0;
|
||||
return (totalSpent / budget.totalAmount).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 0..1 — share of today's envelope already spent.
|
||||
double get dailyProgress {
|
||||
if (dailyLimit <= 0) return spentToday > 0 ? 1 : 0;
|
||||
return (spentToday / dailyLimit).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
class BudgetsList {
|
||||
const BudgetsList({required this.items});
|
||||
|
||||
factory BudgetsList.fromJson(Map<String, dynamic> json) {
|
||||
return BudgetsList(
|
||||
items: asList(json['items']).map(BudgetStatus.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final List<BudgetStatus> items;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class Expense {
|
||||
const Expense({
|
||||
required this.id,
|
||||
required this.budgetId,
|
||||
required this.amount,
|
||||
required this.spentAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
factory Expense.fromJson(Map<String, dynamic> json) {
|
||||
return Expense(
|
||||
id: asInt(json['id']),
|
||||
budgetId: asInt(json['budget_id']),
|
||||
amount: asDouble(json['amount']),
|
||||
spentAt: asDate(json['spent_at']),
|
||||
note: asStringOrNull(json['note']),
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final int budgetId;
|
||||
final double amount;
|
||||
final DateTime spentAt;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
class ExpensesPage {
|
||||
const ExpensesPage({
|
||||
required this.page,
|
||||
required this.totalPages,
|
||||
required this.totalCount,
|
||||
required this.pageSize,
|
||||
required this.totalSum,
|
||||
required this.items,
|
||||
this.budgetId,
|
||||
});
|
||||
|
||||
factory ExpensesPage.fromJson(Map<String, dynamic> json) {
|
||||
return ExpensesPage(
|
||||
page: asInt(json['page'], fallback: 1),
|
||||
totalPages: asInt(json['total_pages'], fallback: 1),
|
||||
totalCount: asInt(json['total_count']),
|
||||
pageSize: asInt(json['page_size'], fallback: 20),
|
||||
totalSum: asDouble(json['total_sum']),
|
||||
budgetId: json['budget_id'] == null ? null : asInt(json['budget_id']),
|
||||
items: asList(json['items']).map(Expense.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static const empty = ExpensesPage(
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalCount: 0,
|
||||
pageSize: 20,
|
||||
totalSum: 0,
|
||||
items: [],
|
||||
);
|
||||
|
||||
final int page;
|
||||
final int totalPages;
|
||||
final int totalCount;
|
||||
final int pageSize;
|
||||
final double totalSum;
|
||||
final int? budgetId;
|
||||
final List<Expense> items;
|
||||
|
||||
bool get hasMore => page < totalPages;
|
||||
|
||||
ExpensesPage copyWithItems(List<Expense> items, {int? page}) {
|
||||
return ExpensesPage(
|
||||
page: page ?? this.page,
|
||||
totalPages: totalPages,
|
||||
totalCount: totalCount,
|
||||
pageSize: pageSize,
|
||||
totalSum: totalSum,
|
||||
budgetId: budgetId,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
enum WeekendPolicy {
|
||||
beforeWeekend('before_weekend', 'До выходных'),
|
||||
afterWeekend('after_weekend', 'После выходных');
|
||||
|
||||
const WeekendPolicy(this.wire, this.label);
|
||||
|
||||
factory WeekendPolicy.fromWire(Object? value) {
|
||||
return WeekendPolicy.values.firstWhere(
|
||||
(policy) => policy.wire == asString(value),
|
||||
orElse: () => WeekendPolicy.beforeWeekend,
|
||||
);
|
||||
}
|
||||
|
||||
final String wire;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class UpcomingPay {
|
||||
const UpcomingPay({
|
||||
required this.date,
|
||||
required this.scheduledDay,
|
||||
required this.percent,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
factory UpcomingPay.fromJson(Map<String, dynamic> json) {
|
||||
return UpcomingPay(
|
||||
date: asDate(json['date']),
|
||||
scheduledDay: asInt(json['scheduled_day']),
|
||||
percent: asDouble(json['percent']),
|
||||
amount: asDouble(json['amount']),
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime date;
|
||||
final int scheduledDay;
|
||||
final double percent;
|
||||
final double amount;
|
||||
}
|
||||
|
||||
class Job {
|
||||
const Job({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.salaryAmount,
|
||||
required this.currency,
|
||||
required this.payDays,
|
||||
required this.firstPayPercent,
|
||||
required this.weekendPolicy,
|
||||
required this.isActive,
|
||||
required this.nextPays,
|
||||
});
|
||||
|
||||
factory Job.fromJson(Map<String, dynamic> json) {
|
||||
final rawDays = json['pay_days'];
|
||||
return Job(
|
||||
id: asInt(json['id']),
|
||||
userId: asInt(json['user_id']),
|
||||
name: asString(json['name']),
|
||||
salaryAmount: asDouble(json['salary_amount']),
|
||||
currency: asString(json['currency'], fallback: 'RUB'),
|
||||
payDays: rawDays is List ? rawDays.map(asInt).toList() : const [],
|
||||
firstPayPercent: asDouble(json['first_pay_percent']),
|
||||
weekendPolicy: WeekendPolicy.fromWire(json['weekend_policy']),
|
||||
isActive: asBool(json['is_active']),
|
||||
nextPays: asList(json['next_pays']).map(UpcomingPay.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final int userId;
|
||||
final String name;
|
||||
final double salaryAmount;
|
||||
final String currency;
|
||||
final List<int> payDays;
|
||||
final double firstPayPercent;
|
||||
final WeekendPolicy weekendPolicy;
|
||||
final bool isActive;
|
||||
final List<UpcomingPay> nextPays;
|
||||
|
||||
UpcomingPay? get nextPay => nextPays.isEmpty ? null : nextPays.first;
|
||||
}
|
||||
|
||||
class JobsList {
|
||||
const JobsList({required this.items});
|
||||
|
||||
factory JobsList.fromJson(Map<String, dynamic> json) {
|
||||
return JobsList(items: asList(json['items']).map(Job.fromJson).toList());
|
||||
}
|
||||
|
||||
final List<Job> items;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// Tolerant JSON coercion helpers.
|
||||
///
|
||||
/// The API is generated from C# records, so numbers may arrive as `int` or
|
||||
/// `double` and nullable strings as `null`; parsing must not crash the UI.
|
||||
library;
|
||||
|
||||
int asInt(Object? value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double asDouble(Object? value, {double fallback = 0}) {
|
||||
if (value is double) return value;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value.replaceAll(',', '.')) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
bool asBool(Object? value, {bool fallback = false}) {
|
||||
if (value is bool) return value;
|
||||
if (value is String) return value.toLowerCase() == 'true';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String asString(Object? value, {String fallback = ''}) {
|
||||
if (value is String) return value;
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String? asStringOrNull(Object? value) {
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parses `YYYY-MM-DD` (and full ISO timestamps) as a local calendar day.
|
||||
DateTime asDate(Object? value) {
|
||||
final raw = asString(value);
|
||||
if (raw.isEmpty) return DateTime.now();
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) return DateTime.now();
|
||||
return DateTime(parsed.year, parsed.month, parsed.day);
|
||||
}
|
||||
|
||||
Map<String, dynamic> asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return value.cast<String, dynamic>();
|
||||
return const {};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> asList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.map(asMap).toList();
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` — the format every date-typed endpoint expects.
|
||||
String formatIsoDate(DateTime date) {
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$month-$day';
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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/budget.dart';
|
||||
import 'package:please_pay_me/data/models/expense.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
class ApiBudgetRepository implements BudgetRepository {
|
||||
const ApiBudgetRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<List<BudgetStatus>> list() async {
|
||||
final json = await _client.getJson('/api/me/budgets');
|
||||
return BudgetsList.fromJson(json).items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> status({int? budgetId}) async {
|
||||
final json = await _client.getJson(
|
||||
'/api/me/budget',
|
||||
query: {if (budgetId != null) 'budget_id': '$budgetId'},
|
||||
);
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> create({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime endDate,
|
||||
DateTime? startDate,
|
||||
}) async {
|
||||
final json = await _client.postJson('/api/me/budgets', body: {
|
||||
'name': name,
|
||||
'total_amount': totalAmount,
|
||||
'end_date': formatIsoDate(endDate),
|
||||
'start_date': startDate == null ? null : formatIsoDate(startDate),
|
||||
'is_active': true,
|
||||
'select': true,
|
||||
});
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> update({
|
||||
required int budgetId,
|
||||
String? name,
|
||||
double? totalAmount,
|
||||
DateTime? endDate,
|
||||
DateTime? startDate,
|
||||
bool resetExpenses = false,
|
||||
}) async {
|
||||
final json = await _client.putJson('/api/me/budgets/$budgetId', body: {
|
||||
if (name != null) 'name': name,
|
||||
if (totalAmount != null) 'total_amount': totalAmount,
|
||||
if (endDate != null) 'end_date': formatIsoDate(endDate),
|
||||
if (startDate != null) 'start_date': formatIsoDate(startDate),
|
||||
'reset_expenses': resetExpenses,
|
||||
});
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> select(int budgetId) async {
|
||||
final json = await _client.postJson('/api/me/budgets/$budgetId/select');
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> setActive(int budgetId, {required bool isActive}) async {
|
||||
final json = await _client.patchJson(
|
||||
'/api/me/budgets/$budgetId/active',
|
||||
body: {'is_active': isActive},
|
||||
);
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int budgetId) => _client.deleteJson('/api/me/budgets/$budgetId');
|
||||
}
|
||||
|
||||
class ApiExpenseRepository implements ExpenseRepository {
|
||||
const ApiExpenseRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<ExpensesPage> page({
|
||||
required int page,
|
||||
int pageSize = 20,
|
||||
int? budgetId,
|
||||
bool all = false,
|
||||
}) async {
|
||||
final json = await _client.getJson('/api/me/expenses', query: {
|
||||
'page': '$page',
|
||||
'page_size': '$pageSize',
|
||||
if (all) 'all': 'true' else if (budgetId != null) 'budget_id': '$budgetId',
|
||||
});
|
||||
return ExpensesPage.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> create({
|
||||
required double amount,
|
||||
String? note,
|
||||
DateTime? spentAt,
|
||||
int? budgetId,
|
||||
}) async {
|
||||
final json = await _client.postJson('/api/me/expenses', body: {
|
||||
'amount': amount,
|
||||
'note': note,
|
||||
'spent_at': spentAt == null ? null : formatIsoDate(spentAt),
|
||||
'budget_id': budgetId,
|
||||
});
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> undoLast({int? budgetId}) async {
|
||||
final json = await _client.deleteJson(
|
||||
'/api/me/expenses/last',
|
||||
query: {if (budgetId != null) 'budget_id': '$budgetId'},
|
||||
);
|
||||
return asDouble(json['deleted_amount']);
|
||||
}
|
||||
}
|
||||
|
||||
class ApiJobRepository implements JobRepository {
|
||||
const ApiJobRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<List<Job>> list() async {
|
||||
final json = await _client.getJson('/api/me/jobs');
|
||||
return JobsList.fromJson(json).items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Job> create({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) async {
|
||||
final json = await _client.postJson('/api/me/jobs', body: {
|
||||
'name': name,
|
||||
'salary_amount': salaryAmount,
|
||||
'pay_days': payDays,
|
||||
'first_pay_percent': firstPayPercent,
|
||||
'weekend_policy': weekendPolicy.wire,
|
||||
'is_active': true,
|
||||
});
|
||||
return Job.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Job> update({
|
||||
required int jobId,
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
bool isActive = true,
|
||||
}) async {
|
||||
final json = await _client.putJson('/api/me/jobs/$jobId', body: {
|
||||
'name': name,
|
||||
'salary_amount': salaryAmount,
|
||||
'pay_days': payDays,
|
||||
'first_pay_percent': firstPayPercent,
|
||||
'weekend_policy': weekendPolicy.wire,
|
||||
'is_active': isActive,
|
||||
});
|
||||
return Job.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int jobId) => _client.deleteJson('/api/me/jobs/$jobId');
|
||||
}
|
||||
|
||||
class ApiUserRepository implements UserRepository {
|
||||
const ApiUserRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<AuthUser> me() async => AuthUser.fromJson(await _client.getJson('/api/me'));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:please_pay_me/data/models/auth_user.dart';
|
||||
import 'package:please_pay_me/data/models/budget.dart';
|
||||
import 'package:please_pay_me/data/models/expense.dart';
|
||||
import 'package:please_pay_me/data/models/job.dart';
|
||||
|
||||
/// Contracts the UI depends on. Implemented by the REST backend and by the
|
||||
/// in-memory demo backend used for previews and tests.
|
||||
abstract interface class BudgetRepository {
|
||||
Future<List<BudgetStatus>> list();
|
||||
|
||||
Future<BudgetStatus> status({int? budgetId});
|
||||
|
||||
Future<BudgetStatus> create({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime endDate,
|
||||
DateTime? startDate,
|
||||
});
|
||||
|
||||
Future<BudgetStatus> update({
|
||||
required int budgetId,
|
||||
String? name,
|
||||
double? totalAmount,
|
||||
DateTime? endDate,
|
||||
DateTime? startDate,
|
||||
bool resetExpenses = false,
|
||||
});
|
||||
|
||||
Future<BudgetStatus> select(int budgetId);
|
||||
|
||||
Future<BudgetStatus> setActive(int budgetId, {required bool isActive});
|
||||
|
||||
Future<void> delete(int budgetId);
|
||||
}
|
||||
|
||||
abstract interface class ExpenseRepository {
|
||||
Future<ExpensesPage> page({
|
||||
required int page,
|
||||
int pageSize = 20,
|
||||
int? budgetId,
|
||||
bool all = false,
|
||||
});
|
||||
|
||||
Future<BudgetStatus> create({
|
||||
required double amount,
|
||||
String? note,
|
||||
DateTime? spentAt,
|
||||
int? budgetId,
|
||||
});
|
||||
|
||||
Future<double> undoLast({int? budgetId});
|
||||
}
|
||||
|
||||
abstract interface class JobRepository {
|
||||
Future<List<Job>> list();
|
||||
|
||||
Future<Job> create({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
});
|
||||
|
||||
Future<Job> update({
|
||||
required int jobId,
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
bool isActive = true,
|
||||
});
|
||||
|
||||
Future<void> delete(int jobId);
|
||||
}
|
||||
|
||||
abstract interface class UserRepository {
|
||||
Future<AuthUser> me();
|
||||
}
|
||||
@@ -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 ?? 'Работа удалена');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:please_pay_me/app/app.dart';
|
||||
import 'package:please_pay_me/core/config/app_config.dart';
|
||||
import 'package:please_pay_me/core/config/env_loader.dart';
|
||||
import 'package:please_pay_me/core/storage/session_storage.dart';
|
||||
import 'package:please_pay_me/features/auth/session_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
await initializeDateFormatting('ru');
|
||||
|
||||
final session = SessionController(
|
||||
config: AppConfig.fromEnvironment(file: await loadEnvFile()),
|
||||
storage: const PrefsSessionStorage(),
|
||||
);
|
||||
final theme = ThemeController();
|
||||
await theme.restore();
|
||||
|
||||
runApp(PleasePayMeApp(session: session, theme: theme));
|
||||
await Future.wait([
|
||||
session.restore(),
|
||||
Future<void>.delayed(const Duration(milliseconds: 850)),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Inter is the closest cross-platform stand-in for SF Pro, so the catalog
|
||||
/// looks the same on Windows/web as it does on device.
|
||||
TextStyle _sf(TextStyle style, {Color? color}) {
|
||||
return GoogleFonts.inter(textStyle: style, color: color);
|
||||
}
|
||||
|
||||
CupertinoTextThemeData _textTheme() {
|
||||
return CupertinoTextThemeData(
|
||||
primaryColor: AppColors.accent,
|
||||
textStyle: _sf(AppTypography.body, color: AppColors.label),
|
||||
actionTextStyle: _sf(AppTypography.body, color: AppColors.accent),
|
||||
tabLabelTextStyle: _sf(AppTypography.caption2, color: AppColors.secondaryLabel),
|
||||
navTitleTextStyle: _sf(AppTypography.headline, color: AppColors.label),
|
||||
navLargeTitleTextStyle: _sf(AppTypography.largeTitle, color: AppColors.label),
|
||||
navActionTextStyle: _sf(AppTypography.body, color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
|
||||
CupertinoThemeData buildLightTheme() => _buildTheme(Brightness.light);
|
||||
|
||||
CupertinoThemeData buildDarkTheme() => _buildTheme(Brightness.dark);
|
||||
|
||||
CupertinoThemeData _buildTheme(Brightness brightness) {
|
||||
return CupertinoThemeData(
|
||||
brightness: brightness,
|
||||
primaryColor: AppColors.accent,
|
||||
primaryContrastingColor: const Color(0xFFFFFFFF),
|
||||
scaffoldBackgroundColor: AppColors.groupedBackground,
|
||||
barBackgroundColor: AppColors.barBackground,
|
||||
applyThemeToAll: true,
|
||||
textTheme: _textTheme(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Android system bars. Dark uses transparent black so the 3-button / gesture
|
||||
/// bar does not get a gray contrast scrim over the tab bar.
|
||||
SystemUiOverlayStyle systemUiOverlayFor(Brightness brightness) {
|
||||
final dark = brightness == Brightness.dark;
|
||||
return SystemUiOverlayStyle(
|
||||
statusBarColor: const Color(0x00000000),
|
||||
statusBarBrightness: brightness,
|
||||
statusBarIconBrightness: dark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: dark ? const Color(0x00000000) : const Color(0x00FFFFFF),
|
||||
systemNavigationBarDividerColor: const Color(0x00000000),
|
||||
systemNavigationBarIconBrightness: dark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'app_theme.dart';
|
||||
export 'theme_controller.dart';
|
||||
export 'tokens.dart';
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum ThemePreference {
|
||||
system,
|
||||
light,
|
||||
dark;
|
||||
|
||||
static ThemePreference parse(String? raw) {
|
||||
return switch (raw) {
|
||||
'light' => ThemePreference.light,
|
||||
'dark' => ThemePreference.dark,
|
||||
_ => ThemePreference.system,
|
||||
};
|
||||
}
|
||||
|
||||
Brightness resolve(Brightness platform) => switch (this) {
|
||||
ThemePreference.system => platform,
|
||||
ThemePreference.light => Brightness.light,
|
||||
ThemePreference.dark => Brightness.dark,
|
||||
};
|
||||
}
|
||||
|
||||
abstract interface class ThemePreferenceStore {
|
||||
Future<String?> read();
|
||||
Future<void> write(String value);
|
||||
}
|
||||
|
||||
class PrefsThemeStore implements ThemePreferenceStore {
|
||||
const PrefsThemeStore();
|
||||
|
||||
static const key = 'ppm_theme_preference';
|
||||
|
||||
@override
|
||||
Future<String?> read() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryThemeStore implements ThemePreferenceStore {
|
||||
MemoryThemeStore([this.value]);
|
||||
|
||||
String? value;
|
||||
|
||||
@override
|
||||
Future<String?> read() async => value;
|
||||
|
||||
@override
|
||||
Future<void> write(String next) async => value = next;
|
||||
}
|
||||
|
||||
/// Survives logout: appearance is a device preference, not a session one.
|
||||
class ThemeController extends ChangeNotifier {
|
||||
ThemeController({ThemePreferenceStore? store}) : _store = store ?? const PrefsThemeStore();
|
||||
|
||||
final ThemePreferenceStore _store;
|
||||
ThemePreference _preference = ThemePreference.system;
|
||||
|
||||
ThemePreference get preference => _preference;
|
||||
|
||||
Brightness resolve(Brightness platform) => _preference.resolve(platform);
|
||||
|
||||
Future<void> restore() async {
|
||||
_preference = ThemePreference.parse(await _store.read());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setPreference(ThemePreference value) async {
|
||||
if (_preference == value) return;
|
||||
_preference = value;
|
||||
notifyListeners();
|
||||
await _store.write(value.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/// Design tokens for the iOS-styled kit.
|
||||
///
|
||||
/// Semantic colors follow Apple HIG (label / separator / grouped background)
|
||||
/// and are declared as [CupertinoDynamicColor] so widgets resolve light & dark
|
||||
/// automatically. Brand tint stays green to match the web cabinet.
|
||||
library;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
abstract final class AppColors {
|
||||
/// Brand tint — replaces `systemBlue` across the kit.
|
||||
static const accent = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFF12885A),
|
||||
darkColor: Color(0xFF3CD68C),
|
||||
);
|
||||
|
||||
static const accentSoft = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFE4F4EC),
|
||||
darkColor: Color(0xFF14301F),
|
||||
);
|
||||
|
||||
// —— iOS system palette ——
|
||||
static const systemRed = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFFF3B30),
|
||||
darkColor: Color(0xFFFF453A),
|
||||
);
|
||||
static const systemOrange = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFFF9500),
|
||||
darkColor: Color(0xFFFF9F0A),
|
||||
);
|
||||
static const systemGreen = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFF34C759),
|
||||
darkColor: Color(0xFF30D158),
|
||||
);
|
||||
static const systemGray = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFF8E8E93),
|
||||
darkColor: Color(0xFF8E8E93),
|
||||
);
|
||||
static const systemGray3 = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFC7C7CC),
|
||||
darkColor: Color(0xFF48484A),
|
||||
);
|
||||
static const systemGray5 = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFE5E5EA),
|
||||
darkColor: Color(0xFF2C2C2E),
|
||||
);
|
||||
static const systemGray6 = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFF2F2F7),
|
||||
darkColor: Color(0xFF1C1C1E),
|
||||
);
|
||||
|
||||
// —— Text hierarchy ——
|
||||
static const label = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFF000000),
|
||||
darkColor: Color(0xFFFFFFFF),
|
||||
);
|
||||
static const secondaryLabel = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0x993C3C43),
|
||||
darkColor: Color(0x99EBEBF5),
|
||||
);
|
||||
static const tertiaryLabel = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0x4D3C3C43),
|
||||
darkColor: Color(0x4DEBEBF5),
|
||||
);
|
||||
|
||||
// —— Separators & surfaces ——
|
||||
static const separator = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0x4A3C3C43),
|
||||
darkColor: Color(0xA6545458),
|
||||
);
|
||||
static const opaqueSeparator = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFC6C6C8),
|
||||
darkColor: Color(0xFF38383A),
|
||||
);
|
||||
static const groupedBackground = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFF2F2F7),
|
||||
darkColor: Color(0xFF000000),
|
||||
);
|
||||
static const groupedSurface = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xFFFFFFFF),
|
||||
darkColor: Color(0xFF1C1C1E),
|
||||
);
|
||||
static const barBackground = CupertinoDynamicColor.withBrightness(
|
||||
color: Color(0xF0F9F9F9),
|
||||
darkColor: Color(0xF01D1D1D),
|
||||
);
|
||||
|
||||
static Color of(BuildContext context, Color color) =>
|
||||
CupertinoDynamicColor.resolve(color, context);
|
||||
}
|
||||
|
||||
/// 4pt grid; iOS content inset is 16.
|
||||
abstract final class AppSpacing {
|
||||
static const double s1 = 4;
|
||||
static const double s2 = 8;
|
||||
static const double s3 = 12;
|
||||
static const double s4 = 16;
|
||||
static const double s5 = 20;
|
||||
static const double s6 = 28;
|
||||
static const double s7 = 40;
|
||||
static const double s8 = 56;
|
||||
|
||||
/// Standard leading/trailing inset for grouped content.
|
||||
static const double gutter = 16;
|
||||
}
|
||||
|
||||
abstract final class AppRadii {
|
||||
static const double sm = 6;
|
||||
static const double md = 10;
|
||||
|
||||
/// Inset-grouped cards & large buttons.
|
||||
static const double lg = 12;
|
||||
static const double xl = 16;
|
||||
static const double capsule = 999;
|
||||
}
|
||||
|
||||
abstract final class AppSizes {
|
||||
static const double buttonLarge = 50;
|
||||
static const double buttonMedium = 44;
|
||||
static const double buttonSmall = 34;
|
||||
static const double rowMinHeight = 44;
|
||||
static const double hairline = 0.5;
|
||||
}
|
||||
|
||||
/// SF Pro text scale (Apple HIG).
|
||||
abstract final class AppTypography {
|
||||
static const largeTitle = TextStyle(fontSize: 34, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0.37);
|
||||
static const title1 = TextStyle(fontSize: 28, height: 1.2, fontWeight: FontWeight.w700, letterSpacing: 0.36);
|
||||
static const title2 = TextStyle(fontSize: 22, height: 1.25, fontWeight: FontWeight.w700, letterSpacing: 0.35);
|
||||
static const title3 = TextStyle(fontSize: 20, height: 1.25, fontWeight: FontWeight.w600, letterSpacing: 0.38);
|
||||
static const headline = TextStyle(fontSize: 17, height: 1.3, fontWeight: FontWeight.w600, letterSpacing: -0.41);
|
||||
static const body = TextStyle(fontSize: 17, height: 1.3, fontWeight: FontWeight.w400, letterSpacing: -0.41);
|
||||
static const callout = TextStyle(fontSize: 16, height: 1.3, fontWeight: FontWeight.w400, letterSpacing: -0.32);
|
||||
static const subhead = TextStyle(fontSize: 15, height: 1.3, fontWeight: FontWeight.w400, letterSpacing: -0.24);
|
||||
static const footnote = TextStyle(fontSize: 13, height: 1.3, fontWeight: FontWeight.w400, letterSpacing: -0.08);
|
||||
static const caption1 = TextStyle(fontSize: 12, height: 1.3, fontWeight: FontWeight.w400);
|
||||
static const caption2 = TextStyle(fontSize: 11, height: 1.3, fontWeight: FontWeight.w400, letterSpacing: 0.07);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// App glyph: rounded green tile with a ruble, same language as [AppIconBadge].
|
||||
class AppBrandMark extends StatelessWidget {
|
||||
const AppBrandMark({super.key, this.size = 96});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = size * 0.235;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.accent),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.of(context, AppColors.accent).withValues(alpha: 0.28),
|
||||
blurRadius: size * 0.28,
|
||||
offset: Offset(0, size * 0.08),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
CupertinoIcons.money_rubl,
|
||||
size: size * 0.52,
|
||||
color: const Color(0xFFFFFFFF),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// iOS 15+ button styles.
|
||||
enum AppButtonStyle { filled, tinted, gray, plain, destructive }
|
||||
|
||||
/// Apple control sizes: large 50pt, medium 44pt, small 34pt.
|
||||
enum AppButtonSize { large, medium, small }
|
||||
|
||||
class AppButton extends StatelessWidget {
|
||||
const AppButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.style = AppButtonStyle.filled,
|
||||
this.size = AppButtonSize.large,
|
||||
this.expanded = true,
|
||||
this.icon,
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final AppButtonStyle style;
|
||||
final AppButtonSize size;
|
||||
final bool expanded;
|
||||
final IconData? icon;
|
||||
final bool loading;
|
||||
|
||||
double get _height => switch (size) {
|
||||
AppButtonSize.large => AppSizes.buttonLarge,
|
||||
AppButtonSize.medium => AppSizes.buttonMedium,
|
||||
AppButtonSize.small => AppSizes.buttonSmall,
|
||||
};
|
||||
|
||||
TextStyle get _textStyle => switch (size) {
|
||||
AppButtonSize.large => AppTypography.headline,
|
||||
AppButtonSize.medium => AppTypography.body,
|
||||
AppButtonSize.small => AppTypography.subhead.copyWith(fontWeight: FontWeight.w600),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = AppColors.of(context, AppColors.accent);
|
||||
final red = AppColors.of(context, AppColors.systemRed);
|
||||
|
||||
final (Color background, Color foreground) = switch (style) {
|
||||
AppButtonStyle.filled => (accent, const Color(0xFFFFFFFF)),
|
||||
AppButtonStyle.tinted => (accent.withValues(alpha: 0.15), accent),
|
||||
AppButtonStyle.gray => (
|
||||
AppColors.of(context, AppColors.systemGray5),
|
||||
AppColors.of(context, AppColors.label),
|
||||
),
|
||||
AppButtonStyle.plain => (const Color(0x00000000), accent),
|
||||
AppButtonStyle.destructive => (red.withValues(alpha: 0.15), red),
|
||||
};
|
||||
|
||||
final disabled = onPressed == null || loading;
|
||||
final content = loading
|
||||
? CupertinoActivityIndicator(
|
||||
radius: size == AppButtonSize.small ? 8 : 10,
|
||||
color: foreground,
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: size == AppButtonSize.small ? 16 : 19, color: foreground),
|
||||
const SizedBox(width: AppSpacing.s2),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(textStyle: _textStyle, color: foreground),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Opacity(
|
||||
opacity: disabled && !loading ? 0.35 : 1,
|
||||
child: SizedBox(
|
||||
height: _height,
|
||||
width: expanded ? double.infinity : null,
|
||||
child: CupertinoButton(
|
||||
onPressed: disabled ? null : onPressed,
|
||||
color: style == AppButtonStyle.plain ? null : background,
|
||||
disabledColor: background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
size == AppButtonSize.small ? AppRadii.md : AppRadii.lg,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: size == AppButtonSize.small ? AppSpacing.s3 : AppSpacing.s4,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
class AppIcon extends StatelessWidget {
|
||||
const AppIcon(
|
||||
this.icon, {
|
||||
super.key,
|
||||
this.size = 22,
|
||||
this.color = AppColors.label,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final double size;
|
||||
final Color color;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Icon(
|
||||
icon,
|
||||
size: size,
|
||||
color: AppColors.of(context, color),
|
||||
semanticLabel: semanticLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings-style rounded square glyph used as a list row leading item.
|
||||
class AppIconBadge extends StatelessWidget {
|
||||
const AppIconBadge({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.size = 29,
|
||||
this.color = AppColors.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, color),
|
||||
borderRadius: BorderRadius.circular(size * 0.235),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: size * 0.6, color: const Color(0xFFFFFFFF)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Semantic text following the iOS type scale.
|
||||
class AppText extends StatelessWidget {
|
||||
const AppText(
|
||||
this.data, {
|
||||
super.key,
|
||||
this.style = AppTypography.body,
|
||||
this.color = AppColors.label,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
});
|
||||
|
||||
const AppText.largeTitle(this.data, {super.key, this.color = AppColors.label, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.largeTitle;
|
||||
|
||||
const AppText.title(this.data, {super.key, this.color = AppColors.label, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.title2;
|
||||
|
||||
const AppText.headline(this.data, {super.key, this.color = AppColors.label, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.headline;
|
||||
|
||||
const AppText.body(this.data, {super.key, this.color = AppColors.label, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.body;
|
||||
|
||||
const AppText.callout(this.data, {super.key, this.color = AppColors.secondaryLabel, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.callout;
|
||||
|
||||
const AppText.subhead(this.data, {super.key, this.color = AppColors.secondaryLabel, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.subhead;
|
||||
|
||||
const AppText.footnote(this.data, {super.key, this.color = AppColors.secondaryLabel, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.footnote;
|
||||
|
||||
const AppText.caption(this.data, {super.key, this.color = AppColors.tertiaryLabel, this.maxLines, this.overflow, this.textAlign})
|
||||
: style = AppTypography.caption1;
|
||||
|
||||
final String data;
|
||||
final TextStyle style;
|
||||
final Color color;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
data,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textAlign: textAlign,
|
||||
style: GoogleFonts.inter(
|
||||
textStyle: style,
|
||||
color: AppColors.of(context, color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase grouped-list header, e.g. `НАСТРОЙКИ`.
|
||||
class AppSectionHeader extends StatelessWidget {
|
||||
const AppSectionHeader(this.text, {super.key, this.padding});
|
||||
|
||||
final String text;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding ??
|
||||
const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s2,
|
||||
),
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: GoogleFonts.inter(
|
||||
textStyle: AppTypography.footnote,
|
||||
color: AppColors.of(context, AppColors.secondaryLabel),
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// Rounded iOS text field with optional grouped-style caption and error.
|
||||
class AppTextField extends StatelessWidget {
|
||||
const AppTextField({
|
||||
super.key,
|
||||
this.label,
|
||||
this.placeholder,
|
||||
this.controller,
|
||||
this.onChanged,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.enabled = true,
|
||||
this.errorText,
|
||||
this.prefixIcon,
|
||||
this.clearable = true,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
final String? label;
|
||||
final String? placeholder;
|
||||
final TextEditingController? controller;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final bool enabled;
|
||||
final String? errorText;
|
||||
final IconData? prefixIcon;
|
||||
final bool clearable;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasError = errorText != null && errorText!.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null) AppSectionHeader(label!, padding: const EdgeInsets.only(bottom: AppSpacing.s2)),
|
||||
Opacity(
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
child: CupertinoTextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
enabled: enabled,
|
||||
maxLines: maxLines,
|
||||
placeholder: placeholder,
|
||||
clearButtonMode: clearable ? OverlayVisibilityMode.editing : OverlayVisibilityMode.never,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s3,
|
||||
vertical: AppSpacing.s3,
|
||||
),
|
||||
prefix: prefixIcon == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s3),
|
||||
child: Icon(
|
||||
prefixIcon,
|
||||
size: 20,
|
||||
color: AppColors.of(context, AppColors.secondaryLabel),
|
||||
),
|
||||
),
|
||||
placeholderStyle: GoogleFonts.inter(
|
||||
textStyle: AppTypography.body,
|
||||
color: AppColors.of(context, AppColors.tertiaryLabel),
|
||||
),
|
||||
style: GoogleFonts.inter(
|
||||
textStyle: AppTypography.body,
|
||||
color: AppColors.of(context, AppColors.label),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.groupedSurface),
|
||||
borderRadius: BorderRadius.circular(AppRadii.md),
|
||||
border: Border.all(
|
||||
color: hasError
|
||||
? AppColors.of(context, AppColors.systemRed)
|
||||
: AppColors.of(context, AppColors.opaqueSeparator),
|
||||
width: AppSizes.hairline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2, left: AppSpacing.s1),
|
||||
child: AppText.footnote(errorText!, color: AppColors.systemRed),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Native iOS spinner.
|
||||
class AppSpinner extends StatelessWidget {
|
||||
const AppSpinner({super.key, this.radius = 12, this.color});
|
||||
|
||||
final double radius;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoActivityIndicator(
|
||||
radius: radius,
|
||||
color: color == null ? null : AppColors.of(context, color!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS progress bar: 4pt capsule track.
|
||||
class AppProgressBar extends StatelessWidget {
|
||||
const AppProgressBar({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.color = AppColors.accent,
|
||||
this.height = 4,
|
||||
});
|
||||
|
||||
/// 0..1
|
||||
final double value;
|
||||
final Color color;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => Stack(
|
||||
children: [
|
||||
Container(color: AppColors.of(context, AppColors.systemGray5)),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
width: constraints.maxWidth * value.clamp(0.0, 1.0),
|
||||
color: AppColors.of(context, color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Pulsing placeholder block used while content loads.
|
||||
class AppSkeleton extends StatefulWidget {
|
||||
const AppSkeleton({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16,
|
||||
this.radius = AppRadii.sm,
|
||||
});
|
||||
|
||||
const AppSkeleton.circle({super.key, required double size})
|
||||
: width = size,
|
||||
height = size,
|
||||
radius = AppRadii.capsule;
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<AppSkeleton> createState() => _AppSkeletonState();
|
||||
}
|
||||
|
||||
class _AppSkeletonState extends State<AppSkeleton> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.45, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
),
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.systemGray5),
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton shaped like an [AppListTile] row.
|
||||
class AppSkeletonRow extends StatelessWidget {
|
||||
const AppSkeletonRow({super.key, this.hasLeading = true});
|
||||
|
||||
final bool hasLeading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.gutter,
|
||||
vertical: AppSpacing.s3,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasLeading) ...[
|
||||
const AppSkeleton.circle(size: 29),
|
||||
const SizedBox(width: AppSpacing.s3),
|
||||
],
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppSkeleton(width: 140, height: 15),
|
||||
SizedBox(height: AppSpacing.s2),
|
||||
AppSkeleton(width: 90, height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
const AppSkeleton(width: 56, height: 15),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_button.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_icon.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
import 'package:please_pay_me/ui/feedback/app_progress.dart';
|
||||
|
||||
/// Centered placeholder for empty collections.
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.icon = CupertinoIcons.tray,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? message;
|
||||
final IconData icon;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s6,
|
||||
vertical: AppSpacing.s7,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(icon, size: 44, color: AppColors.tertiaryLabel),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
AppText.headline(title, textAlign: TextAlign.center),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: AppSpacing.s1),
|
||||
AppText.subhead(message!, textAlign: TextAlign.center),
|
||||
],
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppButton(
|
||||
label: actionLabel!,
|
||||
size: AppButtonSize.medium,
|
||||
style: AppButtonStyle.tinted,
|
||||
expanded: false,
|
||||
onPressed: onAction,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure placeholder with a retry affordance.
|
||||
class AppErrorView extends StatelessWidget {
|
||||
const AppErrorView({super.key, required this.message, this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppEmptyState(
|
||||
icon: CupertinoIcons.exclamationmark_triangle,
|
||||
title: 'Не получилось загрузить',
|
||||
message: message,
|
||||
actionLabel: onRetry == null ? null : 'Повторить',
|
||||
onAction: onRetry,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLoadingView extends StatelessWidget {
|
||||
const AppLoadingView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppSpacing.s7),
|
||||
child: Center(child: AppSpinner()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// iOS has no SnackBar — the platform idiom is a floating blurred capsule
|
||||
/// (AirPods / Silent-mode style). Kept dismiss-free and auto-hiding.
|
||||
class AppToast extends StatelessWidget {
|
||||
const AppToast({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon = CupertinoIcons.check_mark_circled_solid,
|
||||
this.tint = AppColors.accent,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final IconData? icon;
|
||||
final Color tint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s4,
|
||||
vertical: AppSpacing.s3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.groupedSurface).withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x1F000000), blurRadius: 24, offset: Offset(0, 8)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20, color: AppColors.of(context, tint)),
|
||||
const SizedBox(width: AppSpacing.s2),
|
||||
],
|
||||
Flexible(child: AppText.subhead(message, color: AppColors.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showAppToast(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
IconData? icon = CupertinoIcons.check_mark_circled_solid,
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) async {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
final entry = OverlayEntry(
|
||||
builder: (ctx) => Positioned(
|
||||
left: AppSpacing.s5,
|
||||
right: AppSpacing.s5,
|
||||
bottom: MediaQuery.of(ctx).padding.bottom + AppSpacing.s7,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Center(child: AppToast(message: message, icon: icon)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry);
|
||||
await Future<void>.delayed(duration);
|
||||
entry.remove();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Circular avatar — image, initials, or the iOS person placeholder.
|
||||
class AppAvatar extends StatelessWidget {
|
||||
const AppAvatar({
|
||||
super.key,
|
||||
this.imageUrl,
|
||||
this.initials,
|
||||
this.radius = 22,
|
||||
this.backgroundColor = AppColors.systemGray5,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final String? initials;
|
||||
final double radius;
|
||||
final Color backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = radius * 2;
|
||||
final hasImage = imageUrl != null && imageUrl!.isNotEmpty;
|
||||
|
||||
return ClipOval(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: AppColors.of(context, backgroundColor),
|
||||
alignment: Alignment.center,
|
||||
child: hasImage
|
||||
? Image.network(imageUrl!, width: size, height: size, fit: BoxFit.cover)
|
||||
: initials != null && initials!.isNotEmpty
|
||||
? Text(
|
||||
initials!.toUpperCase(),
|
||||
style: GoogleFonts.inter(
|
||||
textStyle: AppTypography.headline.copyWith(fontSize: radius * 0.8),
|
||||
color: AppColors.of(context, AppColors.secondaryLabel),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
CupertinoIcons.person_fill,
|
||||
size: radius,
|
||||
color: AppColors.of(context, AppColors.systemGray),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// Inset-grouped card for free-form content (metrics, summaries).
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.padding = const EdgeInsets.all(AppSpacing.s4),
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry margin;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Container(
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.groupedSurface),
|
||||
borderRadius: BorderRadius.circular(AppRadii.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null) AppText.headline(title!),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
AppText.footnote(subtitle!),
|
||||
],
|
||||
if (title != null || subtitle != null) const SizedBox(height: AppSpacing.s3),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap == null) return card;
|
||||
|
||||
return CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: onTap,
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// Capsule tag / filter pill (iOS has no Material chip — this is the HIG-ish
|
||||
/// equivalent used in Photos & Mail filters).
|
||||
class AppChip extends StatelessWidget {
|
||||
const AppChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.selected = false,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = AppColors.of(context, AppColors.accent);
|
||||
final background = selected ? accent : AppColors.of(context, AppColors.systemGray5);
|
||||
final foreground = selected ? const Color(0xFFFFFFFF) : AppColors.of(context, AppColors.label);
|
||||
|
||||
return CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: onPressed,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s3,
|
||||
vertical: AppSpacing.s2 - 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(AppRadii.capsule),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 15, color: foreground),
|
||||
const SizedBox(width: AppSpacing.s1 + 2),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.inter(
|
||||
textStyle: AppTypography.subhead.copyWith(fontWeight: FontWeight.w500),
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// Inset-grouped section: rounded surface + hairline separators between rows.
|
||||
class AppListSection extends StatelessWidget {
|
||||
const AppListSection({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.header,
|
||||
this.footer,
|
||||
this.separatorIndent = 16,
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final String? header;
|
||||
final String? footer;
|
||||
final double separatorIndent;
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = <Widget>[];
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
rows.add(children[i]);
|
||||
if (i < children.length - 1) {
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: separatorIndent),
|
||||
child: Container(
|
||||
height: AppSizes.hairline,
|
||||
color: AppColors.of(context, AppColors.opaqueSeparator),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (header != null) AppSectionHeader(header!),
|
||||
Container(
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.of(context, AppColors.groupedSurface),
|
||||
borderRadius: BorderRadius.circular(AppRadii.lg),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: rows),
|
||||
),
|
||||
if (footer != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter + 4,
|
||||
AppSpacing.s2,
|
||||
AppSpacing.gutter + 4,
|
||||
0,
|
||||
),
|
||||
child: AppText.footnote(footer!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// Single row of an inset-grouped list (Settings-style).
|
||||
class AppListTile extends StatefulWidget {
|
||||
const AppListTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.value,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.showChevron = true,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
|
||||
/// Secondary gray text aligned to the right (iOS `additionalInfo`).
|
||||
final String? value;
|
||||
|
||||
/// Custom trailing widget — replaces [value] and the chevron.
|
||||
final Widget? trailing;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final bool showChevron;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
State<AppListTile> createState() => _AppListTileState();
|
||||
}
|
||||
|
||||
class _AppListTileState extends State<AppListTile> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tappable = widget.onTap != null;
|
||||
|
||||
final row = Container(
|
||||
color: _pressed
|
||||
? AppColors.of(context, AppColors.systemGray5)
|
||||
: AppColors.of(context, AppColors.groupedSurface),
|
||||
constraints: const BoxConstraints(minHeight: AppSizes.rowMinHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.gutter,
|
||||
vertical: AppSpacing.s2 + 2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.leading != null) ...[
|
||||
widget.leading!,
|
||||
const SizedBox(width: AppSpacing.s3),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppText.body(
|
||||
widget.title,
|
||||
color: widget.destructive ? AppColors.systemRed : AppColors.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
AppText.footnote(widget.subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.trailing != null)
|
||||
widget.trailing!
|
||||
else ...[
|
||||
if (widget.value != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s2),
|
||||
child: AppText.body(widget.value!, color: AppColors.secondaryLabel),
|
||||
),
|
||||
if (tappable && widget.showChevron)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: AppSpacing.s1),
|
||||
child: Icon(
|
||||
CupertinoIcons.chevron_forward,
|
||||
size: 16,
|
||||
color: AppColors.of(context, AppColors.tertiaryLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!tappable) return row;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
onTapDown: (_) => setState(() => _pressed = true),
|
||||
onTapUp: (_) => setState(() => _pressed = false),
|
||||
onTapCancel: () => setState(() => _pressed = false),
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/molecules/app_list_tile.dart';
|
||||
|
||||
/// Settings row with a native iOS switch on the trailing edge.
|
||||
class AppSwitchRow extends StatelessWidget {
|
||||
const AppSwitchRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppListTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
leading: leading,
|
||||
showChevron: false,
|
||||
trailing: CupertinoSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeTrackColor: AppColors.of(context, AppColors.accent),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// Native iOS alert.
|
||||
class AppAlert extends StatelessWidget {
|
||||
const AppAlert({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.confirmLabel = 'OK',
|
||||
this.cancelLabel,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? message;
|
||||
final String confirmLabel;
|
||||
final String? cancelLabel;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoAlertDialog(
|
||||
title: AppText.headline(title),
|
||||
content: message == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: AppText.subhead(message!, textAlign: TextAlign.center),
|
||||
),
|
||||
actions: [
|
||||
if (cancelLabel != null)
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(cancelLabel!),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDefaultAction: !destructive,
|
||||
isDestructiveAction: destructive,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> showAppAlert({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? message,
|
||||
String confirmLabel = 'OK',
|
||||
String? cancelLabel,
|
||||
bool destructive = false,
|
||||
}) {
|
||||
return showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AppAlert(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmLabel: confirmLabel,
|
||||
cancelLabel: cancelLabel,
|
||||
destructive: destructive,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class AppActionSheetAction {
|
||||
const AppActionSheetAction({
|
||||
required this.label,
|
||||
this.destructive = false,
|
||||
this.isDefault = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool destructive;
|
||||
final bool isDefault;
|
||||
}
|
||||
|
||||
Future<int?> showAppActionSheet({
|
||||
required BuildContext context,
|
||||
String? title,
|
||||
String? message,
|
||||
required List<AppActionSheetAction> actions,
|
||||
String cancelLabel = 'Отмена',
|
||||
}) {
|
||||
return showCupertinoModalPopup<int>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: title == null ? null : Text(title),
|
||||
message: message == null ? null : Text(message),
|
||||
actions: [
|
||||
for (var i = 0; i < actions.length; i++)
|
||||
CupertinoActionSheetAction(
|
||||
isDestructiveAction: actions[i].destructive,
|
||||
isDefaultAction: actions[i].isDefault,
|
||||
onPressed: () => Navigator.of(ctx).pop(i),
|
||||
child: Text(actions[i].label),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(cancelLabel),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_text.dart';
|
||||
|
||||
/// Standard iOS navigation bar (44pt) with optional subtitle line.
|
||||
class AppNavBar extends StatelessWidget implements ObstructingPreferredSizeWidget {
|
||||
const AppNavBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.previousPageTitle,
|
||||
this.transparent = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final String? previousPageTitle;
|
||||
final bool transparent;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(44);
|
||||
|
||||
@override
|
||||
bool shouldFullyObstruct(BuildContext context) => !transparent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoNavigationBar(
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
previousPageTitle: previousPageTitle,
|
||||
backgroundColor: transparent
|
||||
? const Color(0x00000000)
|
||||
: AppColors.of(context, AppColors.barBackground),
|
||||
border: transparent
|
||||
? null
|
||||
: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.of(context, AppColors.separator),
|
||||
width: AppSizes.hairline,
|
||||
),
|
||||
),
|
||||
middle: subtitle == null
|
||||
? AppText.headline(title)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppText.headline(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
AppText.caption(
|
||||
subtitle!,
|
||||
color: AppColors.secondaryLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Large-title bar for the root of a scrollable screen.
|
||||
class AppLargeNavBar extends StatelessWidget {
|
||||
const AppLargeNavBar({super.key, required this.title, this.trailing});
|
||||
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoSliverNavigationBar(
|
||||
largeTitle: AppText.largeTitle(title),
|
||||
trailing: trailing,
|
||||
backgroundColor: AppColors.of(context, AppColors.barBackground),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.of(context, AppColors.separator),
|
||||
width: AppSizes.hairline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
import 'package:please_pay_me/ui/atoms/app_button.dart';
|
||||
|
||||
/// Bottom sheet with a native wheel date picker.
|
||||
Future<DateTime?> showAppDatePicker({
|
||||
required BuildContext context,
|
||||
required DateTime initialDate,
|
||||
DateTime? minimumDate,
|
||||
DateTime? maximumDate,
|
||||
String confirmLabel = 'Готово',
|
||||
}) {
|
||||
var selected = initialDate;
|
||||
|
||||
return showCupertinoModalPopup<DateTime>(
|
||||
context: context,
|
||||
builder: (ctx) => Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2),
|
||||
color: AppColors.of(ctx, AppColors.groupedSurface),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoDatePicker(
|
||||
mode: CupertinoDatePickerMode.date,
|
||||
initialDateTime: initialDate,
|
||||
minimumDate: minimumDate,
|
||||
maximumDate: maximumDate,
|
||||
onDateTimeChanged: (value) => selected = value,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s2,
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s3,
|
||||
),
|
||||
child: AppButton(
|
||||
label: confirmLabel,
|
||||
onPressed: () => Navigator.of(ctx).pop(
|
||||
DateTime(selected.year, selected.month, selected.day),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-height modal used for create/edit forms.
|
||||
Future<T?> showAppFormSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
}) {
|
||||
return showCupertinoModalPopup<T>(
|
||||
context: context,
|
||||
builder: (ctx) => Padding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(ctx).padding.top + AppSpacing.s6),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(AppRadii.xl)),
|
||||
child: Builder(builder: builder),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
/// iOS sliding segmented control — the native alternative to tabs.
|
||||
class AppSegmentedControl extends StatelessWidget {
|
||||
const AppSegmentedControl({
|
||||
super.key,
|
||||
required this.labels,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: CupertinoSlidingSegmentedControl<int>(
|
||||
groupValue: index.clamp(0, labels.length - 1),
|
||||
backgroundColor: AppColors.of(context, AppColors.systemGray5),
|
||||
thumbColor: AppColors.of(context, AppColors.groupedSurface),
|
||||
onValueChanged: (value) {
|
||||
if (value != null) onChanged(value);
|
||||
},
|
||||
children: {
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
i: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
labels[i],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(
|
||||
textStyle: AppTypography.subhead.copyWith(
|
||||
fontWeight: i == index ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
color: AppColors.of(context, AppColors.label),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/theme/tokens.dart';
|
||||
|
||||
class AppTabItem {
|
||||
const AppTabItem({required this.icon, required this.label, this.activeIcon});
|
||||
|
||||
final IconData icon;
|
||||
final IconData? activeIcon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Bottom tab bar (iOS): 49pt, hairline top border, tint = brand accent.
|
||||
///
|
||||
/// Extends [CupertinoTabBar] so it can be passed to [CupertinoTabScaffold];
|
||||
/// dynamic colors are resolved by the base class against the active theme.
|
||||
class AppTabBar extends CupertinoTabBar {
|
||||
AppTabBar({
|
||||
super.key,
|
||||
required List<AppTabItem> items,
|
||||
required super.currentIndex,
|
||||
required ValueChanged<int> super.onTap,
|
||||
}) : super(
|
||||
items: [
|
||||
for (final item in items)
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(item.icon),
|
||||
activeIcon: item.activeIcon == null ? null : Icon(item.activeIcon),
|
||||
label: item.label,
|
||||
),
|
||||
],
|
||||
activeColor: AppColors.accent,
|
||||
inactiveColor: AppColors.systemGray,
|
||||
backgroundColor: AppColors.barBackground,
|
||||
border: const Border(
|
||||
top: BorderSide(color: AppColors.separator, width: AppSizes.hairline),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export 'atoms/app_brand_mark.dart';
|
||||
export 'atoms/app_button.dart';
|
||||
export 'atoms/app_icon.dart';
|
||||
export 'atoms/app_text.dart';
|
||||
export 'atoms/app_text_field.dart';
|
||||
export 'feedback/app_progress.dart';
|
||||
export 'feedback/app_skeleton.dart';
|
||||
export 'feedback/app_state_views.dart';
|
||||
export 'feedback/app_toast.dart';
|
||||
export 'molecules/app_avatar.dart';
|
||||
export 'molecules/app_card.dart';
|
||||
export 'molecules/app_chip.dart';
|
||||
export 'molecules/app_list_section.dart';
|
||||
export 'molecules/app_list_tile.dart';
|
||||
export 'molecules/app_switch_row.dart';
|
||||
export 'navigation/app_dialog.dart';
|
||||
export 'navigation/app_nav_bar.dart';
|
||||
export 'navigation/app_pickers.dart';
|
||||
export 'navigation/app_segmented_control.dart';
|
||||
export 'navigation/app_tab_bar.dart';
|
||||
Reference in New Issue
Block a user