feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
@@ -0,0 +1,48 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
@@ -0,0 +1,39 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "nixpkgs000000000000000000000000000000000"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
- platform: android
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
- platform: linux
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
- platform: web
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
- platform: windows
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,12 @@
|
||||
# fintracker_app
|
||||
|
||||
Flutter-клиент fin-tracker (web, Linux, Windows, Android).
|
||||
|
||||
```bash
|
||||
nix develop .#app # из корня репозитория
|
||||
cd app && flutter pub get
|
||||
flutter run -d chrome --dart-define=API_BASE_URL=http://127.0.0.1:8000
|
||||
flutter build web # Caddy раздаёт app/build/web
|
||||
```
|
||||
|
||||
Клиент API — `packages/api_client`, генерируется `just gen-client` из `openapi/openapi.json`.
|
||||
@@ -0,0 +1,14 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- packages/api_client/** # generated by `just gen-client`
|
||||
- build/**
|
||||
- android/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- linux/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_single_quotes: true
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.fintracker.fintracker_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "ru.fintracker.fintracker_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||
// flag during build.
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="fintracker_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.fintracker.fintracker_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.1.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/theme/theme_controller.dart';
|
||||
import 'router.dart';
|
||||
|
||||
class FinTrackerApp extends ConsumerWidget {
|
||||
const FinTrackerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
return MaterialApp.router(
|
||||
title: 'fin-tracker',
|
||||
routerConfig: router,
|
||||
themeMode: themeMode,
|
||||
theme: ThemeData(colorSchemeSeed: const Color(0xFF2E6F5E), useMaterial3: true),
|
||||
darkTheme: ThemeData(
|
||||
colorSchemeSeed: const Color(0xFF2E6F5E),
|
||||
brightness: Brightness.dark,
|
||||
useMaterial3: true,
|
||||
),
|
||||
locale: const Locale('ru'),
|
||||
supportedLocales: const [Locale('ru'), Locale('en')],
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_controller.dart';
|
||||
import '../config.dart';
|
||||
|
||||
/// Authenticated client: bearer header on every call, one transparent refresh on 401.
|
||||
final apiProvider = Provider<FintrackerApi>((ref) {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl(),
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
dio.interceptors.add(DateQueryInterceptor());
|
||||
dio.interceptors.add(_AuthInterceptor(ref));
|
||||
return FintrackerApi(dio: dio, interceptors: const []);
|
||||
});
|
||||
|
||||
/// Query dates as `YYYY-MM-DD`, which is what `format: date` in the spec means.
|
||||
///
|
||||
/// openapi-generator types a `format: date` query parameter as `DateTime` and hands it to
|
||||
/// Dio unconverted, so the wire value becomes `DateTime.toString()` —
|
||||
/// `2026-09-18 10:31:29.084`, which FastAPI rejects with 422. Every date-typed query
|
||||
/// parameter in the spec is a `date` (none is a `date-time`), so truncating here is exactly
|
||||
/// the intended encoding rather than a lossy guess. Lives in the app, not in the generated
|
||||
/// package, because `just gen-client` overwrites the latter.
|
||||
class DateQueryInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.queryParameters = {
|
||||
for (final e in options.queryParameters.entries)
|
||||
e.key: e.value is DateTime ? _isoDate(e.value as DateTime) : e.value,
|
||||
};
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
static String _isoDate(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
class _AuthInterceptor extends QueuedInterceptor {
|
||||
_AuthInterceptor(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final token = _ref.read(authControllerProvider).accessToken;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final retried = err.requestOptions.extra['retried'] == true;
|
||||
if (err.response?.statusCode != 401 || retried) return handler.next(err);
|
||||
|
||||
final auth = _ref.read(authControllerProvider.notifier);
|
||||
if (!await auth.refreshSession()) {
|
||||
await auth.logout();
|
||||
return handler.next(err);
|
||||
}
|
||||
final token = _ref.read(authControllerProvider).accessToken;
|
||||
final opts = err.requestOptions
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..extra['retried'] = true;
|
||||
try {
|
||||
handler.resolve(await Dio(BaseOptions(baseUrl: opts.baseUrl)).fetch(opts));
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'api_client.dart';
|
||||
|
||||
/// The signed-in user, used by Обзор and Настройки.
|
||||
final meProvider = FutureProvider<UserOut>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getAuthApi().authMe();
|
||||
return r.data!;
|
||||
});
|
||||
|
||||
/// Backend health, used by Обзор.
|
||||
final healthProvider = FutureProvider<Health>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getHealthApi().healthCheck();
|
||||
return r.data!;
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Mirrors the `version:` line in `pubspec.yaml`. Flutter has no runtime
|
||||
/// pubspec reader without an extra dependency (`package_info_plus`), so this
|
||||
/// is a hand-maintained literal for now — bump it alongside pubspec.yaml.
|
||||
const appVersion = '0.1.0+1';
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import 'token_store.dart';
|
||||
|
||||
enum AuthStatus { unknown, signedOut, signedIn }
|
||||
|
||||
class AuthState {
|
||||
const AuthState(this.status, {this.accessToken, this.email});
|
||||
final AuthStatus status;
|
||||
final String? accessToken;
|
||||
final String? email;
|
||||
|
||||
bool get signedIn => status == AuthStatus.signedIn;
|
||||
}
|
||||
|
||||
final tokenStoreProvider = Provider<TokenStore>((_) => TokenStore());
|
||||
|
||||
/// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse.
|
||||
final authDioProvider = Provider<Dio>(
|
||||
(_) => Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl(),
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
)),
|
||||
);
|
||||
|
||||
final authControllerProvider = NotifierProvider<AuthController, AuthState>(AuthController.new);
|
||||
|
||||
class AuthController extends Notifier<AuthState> {
|
||||
@override
|
||||
AuthState build() {
|
||||
Future.microtask(_restore);
|
||||
return const AuthState(AuthStatus.unknown);
|
||||
}
|
||||
|
||||
AuthApi get _api => AuthApi(ref.read(authDioProvider));
|
||||
TokenStore get _store => ref.read(tokenStoreProvider);
|
||||
|
||||
Future<void> _restore() async {
|
||||
final refresh = await _store.readRefresh();
|
||||
if (refresh == null) {
|
||||
state = const AuthState(AuthStatus.signedOut);
|
||||
return;
|
||||
}
|
||||
final ok = await refreshSession(refresh);
|
||||
if (!ok) state = const AuthState(AuthStatus.signedOut);
|
||||
}
|
||||
|
||||
Future<String?> login(String email, String password) async {
|
||||
try {
|
||||
final r = await _api.authLogin(loginRequest: LoginRequest(email: email, password: password));
|
||||
await _accept(r.data!);
|
||||
return null;
|
||||
} on DioException catch (e) {
|
||||
return problemMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange a refresh token for a new pair. Returns false when it is no longer valid.
|
||||
Future<bool> refreshSession([String? refresh]) async {
|
||||
final token = refresh ?? await _store.readRefresh();
|
||||
if (token == null) return false;
|
||||
try {
|
||||
final r = await _api.authRefresh(refreshRequest: RefreshRequest(refreshToken: token));
|
||||
await _accept(r.data!);
|
||||
return true;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 401) await _store.clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _accept(TokenPair pair) async {
|
||||
await _store.writeRefresh(pair.refreshToken);
|
||||
state = AuthState(AuthStatus.signedIn, accessToken: pair.accessToken, email: state.email);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final refresh = await _store.readRefresh();
|
||||
if (refresh != null) {
|
||||
try {
|
||||
await _api.authLogout(refreshRequest: RefreshRequest(refreshToken: refresh));
|
||||
} on DioException {
|
||||
// the server may already consider it revoked; local state wins
|
||||
}
|
||||
}
|
||||
await _store.clear();
|
||||
state = const AuthState(AuthStatus.signedOut);
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable text from an RFC 7807 body, falling back to the transport error.
|
||||
String problemMessage(DioException e) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map) {
|
||||
final detail = data['detail'] ?? data['title'];
|
||||
if (detail is String && detail.isNotEmpty) return detail;
|
||||
}
|
||||
return switch (e.type) {
|
||||
DioExceptionType.connectionError ||
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'Сервер недоступен',
|
||||
_ => e.message ?? 'Ошибка запроса',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Refresh token at rest: Keystore / libsecret / DPAPI; on web — localStorage
|
||||
/// (single-user, TLS-only deployment; see plan §7 open question 1).
|
||||
class TokenStore {
|
||||
TokenStore([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
static const _refreshKey = 'refresh_token';
|
||||
|
||||
Future<String?> readRefresh() => _storage.read(key: _refreshKey);
|
||||
Future<void> writeRefresh(String token) => _storage.write(key: _refreshKey, value: token);
|
||||
Future<void> clear() => _storage.delete(key: _refreshKey);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Backend origin. Override at build time:
|
||||
/// `flutter run --dart-define=API_BASE_URL=https://fin.example.com`.
|
||||
/// On web the default is the page's own origin (Caddy serves app and API together).
|
||||
String apiBaseUrl() {
|
||||
const fromEnv = String.fromEnvironment('API_BASE_URL');
|
||||
if (fromEnv.isNotEmpty) return fromEnv;
|
||||
if (kIsWeb) return Uri.base.origin;
|
||||
return 'http://127.0.0.1:8000';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Fixed categorical order from the design system's palette (dataviz skill):
|
||||
/// slot 1 blue, slot 2 orange, slot 3 aqua, slot 4 yellow, slot 5 magenta.
|
||||
/// Assigned to entities in this fixed order — never cycled, never by rank.
|
||||
class ChartColors {
|
||||
const ChartColors._();
|
||||
|
||||
static const slot1Blue = Color(0xFF2A78D6);
|
||||
static const slot2Orange = Color(0xFFEB6834);
|
||||
static const slot3Aqua = Color(0xFF1BAF7A);
|
||||
static const slot4Yellow = Color(0xFFEDA100);
|
||||
static const slot5Magenta = Color(0xFFE87BA4);
|
||||
|
||||
/// This app's fixed assignment for cashflow charts.
|
||||
static const income = slot1Blue;
|
||||
static const expense = slot2Orange;
|
||||
static const baseline = slot3Aqua;
|
||||
static const oneOff = slot4Yellow;
|
||||
static const savingsTransfer = slot5Magenta;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _prefsKey = 'theme_mode';
|
||||
|
||||
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(ThemeModeController.new);
|
||||
|
||||
/// Persists the chosen [ThemeMode] to this device via `shared_preferences`.
|
||||
class ThemeModeController extends Notifier<ThemeMode> {
|
||||
@override
|
||||
ThemeMode build() {
|
||||
Future.microtask(_restore);
|
||||
return ThemeMode.system;
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_prefsKey);
|
||||
state = ThemeMode.values.asNameMap()[saved] ?? ThemeMode.system;
|
||||
}
|
||||
|
||||
Future<void> setMode(ThemeMode mode) async {
|
||||
state = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, mode.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Russian month/date helpers that avoid needing `initializeDateFormatting`
|
||||
/// (which `intl`'s locale-aware `DateFormat` symbols require) for the small
|
||||
/// set of formats this app needs.
|
||||
library;
|
||||
|
||||
const _monthsNominative = [
|
||||
'январь',
|
||||
'февраль',
|
||||
'март',
|
||||
'апрель',
|
||||
'май',
|
||||
'июнь',
|
||||
'июль',
|
||||
'август',
|
||||
'сентябрь',
|
||||
'октябрь',
|
||||
'ноябрь',
|
||||
'декабрь',
|
||||
];
|
||||
|
||||
const _monthsShort = [
|
||||
'янв',
|
||||
'фев',
|
||||
'мар',
|
||||
'апр',
|
||||
'май',
|
||||
'июн',
|
||||
'июл',
|
||||
'авг',
|
||||
'сен',
|
||||
'окт',
|
||||
'ноя',
|
||||
'дек',
|
||||
];
|
||||
|
||||
/// `'сентябрь 2026'`.
|
||||
String ruMonthYear(DateTime d) {
|
||||
final name = _monthsNominative[d.month - 1];
|
||||
return '${name[0].toUpperCase()}${name.substring(1)} ${d.year}';
|
||||
}
|
||||
|
||||
/// `'сен 2026'`, for compact chart/table labels.
|
||||
String ruMonthYearShort(DateTime d) => '${_monthsShort[d.month - 1]} ${d.year}';
|
||||
|
||||
/// `'2026-09'`, the `month` query parameter format the API expects.
|
||||
String monthKey(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Parses a `'YYYY-MM'` key back into a [DateTime] (first of month, UTC).
|
||||
DateTime parseMonthKey(String key) {
|
||||
final parts = key.split('-');
|
||||
return DateTime.utc(int.parse(parts[0]), int.parse(parts[1]));
|
||||
}
|
||||
|
||||
/// `'12.09.2026'`.
|
||||
String ruDate(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_controller.dart';
|
||||
|
||||
/// Renders an [AsyncValue]: the data on success, a spinner while loading, and
|
||||
/// a retry-capable error view otherwise. Keeps the loading/error boilerplate
|
||||
/// out of every screen that watches a provider.
|
||||
class AsyncValueView<T> extends StatelessWidget {
|
||||
const AsyncValueView({required this.value, required this.data, this.onRetry, super.key});
|
||||
|
||||
final AsyncValue<T> value;
|
||||
final Widget Function(T data) data;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return value.when(
|
||||
data: data,
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(_message(error), textAlign: TextAlign.center),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(onPressed: onRetry, child: const Text('Повторить')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _message(Object error) =>
|
||||
error is DioException ? problemMessage(error) : error.toString();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A centered placeholder for a screen or list section with nothing to show yet.
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({required this.message, this.icon = Icons.inbox_outlined, super.key});
|
||||
|
||||
final String message;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Currency symbols for the instruments we expect to see. Falls back to the
|
||||
/// ISO code itself (e.g. 'USDT') when we don't have a nicer glyph.
|
||||
const _currencySymbols = {
|
||||
'RUB': '₽',
|
||||
'USD': '\$',
|
||||
'EUR': '€',
|
||||
};
|
||||
|
||||
/// Formats a decimal-string amount (as the API sends it, to avoid double
|
||||
/// rounding errors) with `intl`'s ru_RU rules and a currency symbol.
|
||||
class MoneyText extends StatelessWidget {
|
||||
const MoneyText(this.amount, {required this.currency, super.key, this.style});
|
||||
|
||||
/// The amount as a string, e.g. `'1234.5'` — never a [double].
|
||||
final String amount;
|
||||
final String currency;
|
||||
final TextStyle? style;
|
||||
|
||||
static String format(String amount, String currency) {
|
||||
final value = Decimal.parse(amount).toDouble();
|
||||
final symbol = _currencySymbols[currency] ?? currency;
|
||||
final formatter = NumberFormat.currency(locale: 'ru_RU', symbol: symbol, decimalDigits: 2);
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(format(amount, currency), style: style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt];
|
||||
|
||||
String _roleLabel(AccountRole role) => switch (role) {
|
||||
AccountRole.liquid => 'Ликвидные',
|
||||
AccountRole.savings => 'Сбережения',
|
||||
AccountRole.investment => 'Инвестиции',
|
||||
AccountRole.debt => 'Долги',
|
||||
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
String _kindLabel(AccountKind kind) => switch (kind) {
|
||||
AccountKind.zmCash => 'Наличные',
|
||||
AccountKind.zmCard => 'Карта',
|
||||
AccountKind.zmChecking => 'Расчётный счёт',
|
||||
AccountKind.zmDeposit => 'Вклад',
|
||||
AccountKind.zmLoan => 'Кредит',
|
||||
AccountKind.zmEmoney => 'Электронные деньги',
|
||||
AccountKind.zmDebt => 'Долг',
|
||||
AccountKind.broker => 'Брокерский счёт',
|
||||
AccountKind.manualAsset => 'Актив вручную',
|
||||
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
/// Счета: every account grouped by role, with in-place role and
|
||||
/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts
|
||||
/// collapse into their own section at the bottom regardless of role.
|
||||
class AccountsPage extends ConsumerWidget {
|
||||
const AccountsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accounts = ref.watch(accountsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Счета')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(accountsProvider),
|
||||
child: AsyncValueView(
|
||||
value: accounts,
|
||||
onRetry: () => ref.invalidate(accountsProvider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(
|
||||
icon: Icons.account_balance_outlined,
|
||||
message: 'Счетов ещё нет — нужна синхронизация.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final active = rows.where((a) => !a.archived).toList();
|
||||
final archived = rows.where((a) => a.archived).toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final role in _roleOrder)
|
||||
if (active.any((a) => a.role == role))
|
||||
_RoleSection(
|
||||
title: _roleLabel(role),
|
||||
accounts: active.where((a) => a.role == role).toList(),
|
||||
),
|
||||
if (archived.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: Text('Архивные (${archived.length})'),
|
||||
initiallyExpanded: false,
|
||||
children: [for (final a in archived) _AccountTile(account: a)],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSection extends StatelessWidget {
|
||||
const _RoleSection({required this.title, required this.accounts});
|
||||
|
||||
final String title;
|
||||
final List<AccountOut> accounts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
for (final a in accounts) _AccountTile(account: a),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountTile extends ConsumerStatefulWidget {
|
||||
const _AccountTile({required this.account});
|
||||
|
||||
final AccountOut account;
|
||||
|
||||
@override
|
||||
ConsumerState<_AccountTile> createState() => _AccountTileState();
|
||||
}
|
||||
|
||||
class _AccountTileState extends ConsumerState<_AccountTile> {
|
||||
bool _saving = false;
|
||||
|
||||
Future<void> _patch(AccountPatch patch) async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getAccountsApi().accountsPatch(
|
||||
accountId: widget.account.id,
|
||||
accountPatch: patch,
|
||||
);
|
||||
ref.invalidate(accountsProvider);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final a = widget.account;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(a.name, style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(
|
||||
'${_kindLabel(a.kind)} · ${a.currency}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
a.balance ?? '0',
|
||||
currency: a.currency,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<AccountRole>(
|
||||
initialValue: a.role,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Роль', isDense: true),
|
||||
items: [
|
||||
for (final r in _roleOrder)
|
||||
DropdownMenuItem(value: r, child: Text(_roleLabel(r))),
|
||||
],
|
||||
onChanged: _saving ? null : (role) {
|
||||
if (role != null) _patch(AccountPatch(role: role));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
children: [
|
||||
const Text('В капитал', style: TextStyle(fontSize: 11)),
|
||||
Switch(
|
||||
value: a.includeInNetWorth,
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
/// Every account, archived included — screens decide what to show.
|
||||
final accountsProvider = FutureProvider.autoDispose<List<AccountOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getAccountsApi().accountsList();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// `account_id -> name`, for screens that only carry the id (transactions, rules).
|
||||
final accountNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
|
||||
final accounts = ref.watch(accountsProvider).valueOrNull ?? const [];
|
||||
return {for (final a in accounts) a.id: a.name};
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
const _incomeColor = ChartColors.income;
|
||||
const _expenseColor = ChartColors.expense;
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
/// Потоки: 24 months of cashflow, a grouped bar chart on top and the full
|
||||
/// breakdown as a scrollable table below. Tapping a row opens Категории for
|
||||
/// that month.
|
||||
class CashflowPage extends ConsumerWidget {
|
||||
const CashflowPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final monthly = ref.watch(cashflowMonthly24Provider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Потоки')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(cashflowMonthly24Provider),
|
||||
child: AsyncValueView(
|
||||
value: monthly,
|
||||
onRetry: () => ref.invalidate(cashflowMonthly24Provider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(
|
||||
icon: Icons.swap_horiz_outlined,
|
||||
message: 'Данных о потоках ещё нет — нужна синхронизация.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _Legend(),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
child: SizedBox(
|
||||
width: (rows.length * 56).toDouble().clamp(320, double.infinity),
|
||||
child: _CashflowChart(rows: rows),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_Table(rows: rows),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
const _Legend();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: const [
|
||||
_LegendDot(color: _incomeColor, label: 'Доход'),
|
||||
SizedBox(width: 16),
|
||||
_LegendDot(color: _expenseColor, label: 'Расход'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendDot extends StatelessWidget {
|
||||
const _LegendDot({required this.color, required this.label});
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CashflowChart extends StatelessWidget {
|
||||
const _CashflowChart({required this.rows});
|
||||
|
||||
final List<CashFlowMonth> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxY = rows.fold<double>(
|
||||
0,
|
||||
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
|
||||
);
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY * 1.1,
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.toInt();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 10)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final label = rodIndex == 0 ? 'Доход' : 'Расход';
|
||||
return BarTooltipItem(
|
||||
'$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}',
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
barGroups: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(toY: _d(rows[i].incomeRub), color: _incomeColor, width: 8),
|
||||
BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8),
|
||||
],
|
||||
barsSpace: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Table extends StatelessWidget {
|
||||
const _Table({required this.rows});
|
||||
|
||||
final List<CashFlowMonth> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerStyle = Theme.of(context).textTheme.labelMedium;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Месяц', style: headerStyle)),
|
||||
DataColumn(label: Text('Доход', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Расход', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Базовые', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Разовые', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows.reversed)
|
||||
DataRow(
|
||||
onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'),
|
||||
cells: [
|
||||
DataCell(Text(ruMonthYearShort(r.month))),
|
||||
DataCell(MoneyText(r.incomeRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.expenseRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.baselineRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.oneOffRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')),
|
||||
DataCell(Text(r.savingsRate == null
|
||||
? '—'
|
||||
: '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
/// The last 24 months, oldest first (as the API returns them).
|
||||
final cashflowMonthly24Provider = FutureProvider.autoDispose<List<CashFlowMonth>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
class _Group {
|
||||
_Group(this.rootId, this.rootName);
|
||||
final int? rootId;
|
||||
final String rootName;
|
||||
final List<SpendingRow> rows = [];
|
||||
|
||||
Decimal get total => rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub));
|
||||
}
|
||||
|
||||
List<_Group> _group(List<SpendingRow> rows) {
|
||||
final byRoot = <int?, _Group>{};
|
||||
for (final r in rows) {
|
||||
final key = r.categoryId == null ? null : (r.rootCategoryId ?? r.categoryId);
|
||||
final group = byRoot.putIfAbsent(
|
||||
key,
|
||||
() => _Group(key, key == null ? 'Без категории' : (r.rootCategoryName ?? r.categoryName ?? '—')),
|
||||
);
|
||||
group.rows.add(r);
|
||||
}
|
||||
final groups = byRoot.values.toList()..sort((a, b) => b.total.compareTo(a.total));
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// Категории: expenses of one month, root-grouped, largest first.
|
||||
class CategoriesPage extends ConsumerStatefulWidget {
|
||||
const CategoriesPage({this.initialMonth, super.key});
|
||||
|
||||
final String? initialMonth;
|
||||
|
||||
@override
|
||||
ConsumerState<CategoriesPage> createState() => _CategoriesPageState();
|
||||
}
|
||||
|
||||
class _CategoriesPageState extends ConsumerState<CategoriesPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialMonth;
|
||||
if (initial != null) {
|
||||
Future.microtask(() => ref.read(selectedSpendingMonthProvider.notifier).state = initial);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickMonth() async {
|
||||
final current = parseMonthKey(ref.read(selectedSpendingMonthProvider));
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: current,
|
||||
firstDate: DateTime.utc(2015),
|
||||
lastDate: DateTime.now(),
|
||||
helpText: 'Выберите месяц',
|
||||
initialDatePickerMode: DatePickerMode.year,
|
||||
);
|
||||
if (picked != null) {
|
||||
ref.read(selectedSpendingMonthProvider.notifier).state =
|
||||
monthKey(DateTime(picked.year, picked.month));
|
||||
}
|
||||
}
|
||||
|
||||
void _shiftMonth(int delta) {
|
||||
final current = parseMonthKey(ref.read(selectedSpendingMonthProvider));
|
||||
final next = DateTime.utc(current.year, current.month + delta);
|
||||
ref.read(selectedSpendingMonthProvider.notifier).state = monthKey(next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final month = ref.watch(selectedSpendingMonthProvider);
|
||||
final spending = ref.watch(spendingProvider(month));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Категории')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(spendingProvider(month)),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () => _shiftMonth(-1),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _pickMonth,
|
||||
child: Text(
|
||||
ruMonthYear(parseMonthKey(month)),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: () => _shiftMonth(1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: spending,
|
||||
onRetry: () => ref.invalidate(spendingProvider(month)),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.donut_small_outlined,
|
||||
message: 'Данных за этот месяц нет — нужна синхронизация.',
|
||||
);
|
||||
}
|
||||
final groups = _group(rows);
|
||||
final total = groups.fold(Decimal.zero, (a, g) => a + g.total);
|
||||
final maxTotal = groups.first.total;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Всего расходов', style: Theme.of(context).textTheme.bodyLarge),
|
||||
MoneyText(
|
||||
total.toString(),
|
||||
currency: 'RUB',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
for (final g in groups) _GroupTile(group: g, maxTotal: maxTotal),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupTile extends StatelessWidget {
|
||||
const _GroupTile({required this.group, required this.maxTotal});
|
||||
|
||||
final _Group group;
|
||||
final Decimal maxTotal;
|
||||
|
||||
bool get _flat =>
|
||||
group.rows.length == 1 && (group.rootId == null || group.rows.first.categoryId == group.rootId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_flat) {
|
||||
return _CategoryBar(
|
||||
name: group.rootName,
|
||||
amount: group.total,
|
||||
maxAmount: maxTotal,
|
||||
bold: true,
|
||||
);
|
||||
}
|
||||
final children = [...group.rows]
|
||||
..sort((a, b) => Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub)));
|
||||
return ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: _CategoryBar(name: group.rootName, amount: group.total, maxAmount: maxTotal, bold: true),
|
||||
children: [
|
||||
for (final r in children)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: _CategoryBar(
|
||||
name: r.categoryId == group.rootId ? 'Без подкатегории' : (r.categoryName ?? '—'),
|
||||
amount: Decimal.parse(r.amountRub),
|
||||
maxAmount: group.total,
|
||||
bold: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryBar extends StatelessWidget {
|
||||
const _CategoryBar({
|
||||
required this.name,
|
||||
required this.amount,
|
||||
required this.maxAmount,
|
||||
required this.bold,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final Decimal amount;
|
||||
final Decimal maxAmount;
|
||||
final bool bold;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = maxAmount == Decimal.zero
|
||||
? 0.0
|
||||
: (amount / maxAmount).toDouble().clamp(0.0, 1.0);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final style = bold
|
||||
? Theme.of(context).textTheme.bodyLarge
|
||||
: Theme.of(context).textTheme.bodyMedium;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(name, style: style)),
|
||||
MoneyText(amount.toString(), currency: 'RUB', style: style),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 6, color: scheme.surfaceContainerHighest),
|
||||
Container(
|
||||
height: 6,
|
||||
width: constraints.maxWidth * ratio,
|
||||
color: bold ? ChartColors.expense : ChartColors.expense.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
|
||||
/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed.
|
||||
final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// `category_id -> name`, for screens that only carry the id.
|
||||
final categoryNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
|
||||
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const [];
|
||||
return {for (final c in categories) c.id: c.name};
|
||||
});
|
||||
|
||||
/// Spending by category for one month (`YYYY-MM`); null means "the latest month".
|
||||
final spendingProvider =
|
||||
FutureProvider.autoDispose.family<List<SpendingRow>, String?>((ref, month) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// The month currently selected on the Категории screen, `YYYY-MM`.
|
||||
final selectedSpendingMonthProvider = StateProvider.autoDispose<String>(
|
||||
(ref) => monthKey(DateTime.now()),
|
||||
);
|
||||
@@ -0,0 +1,478 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/theme/chart_colors.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
Color _severityColor(String severity) {
|
||||
final s = severity.toLowerCase();
|
||||
if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B);
|
||||
if (s.contains('warn')) return const Color(0xFFFAB219);
|
||||
if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C);
|
||||
return const Color(0xFFEC835A);
|
||||
}
|
||||
|
||||
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
|
||||
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
|
||||
/// a data-quality summary linking to the findings.
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
invalidateHomeProviders(ref);
|
||||
}
|
||||
}
|
||||
|
||||
void _showDataQuality(List<DataQualityRow> rows) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Качество данных', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
if (rows.isEmpty) const Text('Проблем не найдено.'),
|
||||
for (final row in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4, right: 8),
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration:
|
||||
BoxDecoration(color: _severityColor(row.severity), shape: BoxShape.circle),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${row.checkName} (${row.count})',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(row.detail),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final breakdown = ref.watch(netWorthBreakdownProvider);
|
||||
final series = ref.watch(netWorthSeriesProvider);
|
||||
final thisMonth = ref.watch(cashflowThisMonthProvider);
|
||||
final last12 = ref.watch(cashflowLast12Provider);
|
||||
final runway = ref.watch(runwayProvider);
|
||||
final status = ref.watch(metricsStatusProvider);
|
||||
final dataQuality = ref.watch(dataQualityProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Обзор'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Пересчитать метрики',
|
||||
icon: _refreshing
|
||||
? const SizedBox(
|
||||
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _refreshing ? null : _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => invalidateHomeProviders(ref),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AsyncValueView(
|
||||
value: status,
|
||||
onRetry: () => ref.invalidate(metricsStatusProvider),
|
||||
data: (log) {
|
||||
final at = log?.finishedAt ?? log?.startedAt;
|
||||
final text = at == null
|
||||
? 'Данные ещё не пересчитывались'
|
||||
: 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}';
|
||||
return Text(text, style: Theme.of(context).textTheme.bodySmall);
|
||||
},
|
||||
),
|
||||
),
|
||||
AsyncValueView(
|
||||
value: dataQuality,
|
||||
data: (rows) => ActionChip(
|
||||
avatar: Icon(
|
||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity),
|
||||
),
|
||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AsyncValueView(
|
||||
value: breakdown,
|
||||
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
|
||||
data: (b) => _NetWorthTiles(breakdown: b),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AsyncValueView(
|
||||
value: thisMonth,
|
||||
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
|
||||
data: (m) => _MonthTiles(month: m),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AsyncValueView(
|
||||
value: runway,
|
||||
onRetry: () => ref.invalidate(runwayProvider),
|
||||
data: (r) => _RunwayTile(runway: r),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 900;
|
||||
final netWorthChart = _ChartCard(
|
||||
title: 'Капитал за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
: _NetWorthChart(rows: rows),
|
||||
),
|
||||
);
|
||||
final cashflowChart = _ChartCard(
|
||||
title: 'Доход и расход, 12 месяцев',
|
||||
child: AsyncValueView(
|
||||
value: last12,
|
||||
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
||||
: _IncomeExpenseChart(rows: rows),
|
||||
),
|
||||
);
|
||||
if (wide) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: netWorthChart),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: cashflowChart),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [netWorthChart, const SizedBox(height: 16), cashflowChart],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.go('/sync'),
|
||||
icon: const Icon(Icons.sync, size: 16),
|
||||
label: const Text('Синхронизация'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatTile extends StatelessWidget {
|
||||
const _StatTile({required this.label, required this.value});
|
||||
final String label;
|
||||
final Widget value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 168,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!, child: value),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetWorthTiles extends StatelessWidget {
|
||||
const _NetWorthTiles({required this.breakdown});
|
||||
final NetWorthBreakdown breakdown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthTiles extends StatelessWidget {
|
||||
const _MonthTiles({required this.month});
|
||||
final CashFlowMonth? month;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (month == null) {
|
||||
return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.');
|
||||
}
|
||||
final rateText = month!.savingsRate == null ? '—' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Норма сбережений', value: Text(rateText)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RunwayTile extends StatelessWidget {
|
||||
const _RunwayTile({required this.runway});
|
||||
final RunwayOut runway;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = runway.runwayMonths == null
|
||||
? '—'
|
||||
: '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.';
|
||||
return _StatTile(label: 'Запас хода (runway)', value: Text(text));
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartCard extends StatelessWidget {
|
||||
const _ChartCard({required this.title, required this.child});
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: 200, child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetWorthChart extends StatelessWidget {
|
||||
const _NetWorthChart({required this.rows});
|
||||
final List<NetWorthDay> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spots = [
|
||||
for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)),
|
||||
];
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: const FlTitlesData(
|
||||
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => [
|
||||
for (final s in spots)
|
||||
LineTooltipItem(
|
||||
MoneyText.format(s.y.toStringAsFixed(2), 'RUB'),
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: false,
|
||||
barWidth: 2,
|
||||
color: ChartColors.slot1Blue,
|
||||
dotData: const FlDotData(show: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IncomeExpenseChart extends StatelessWidget {
|
||||
const _IncomeExpenseChart({required this.rows});
|
||||
final List<CashFlowMonth> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxY = rows.fold<double>(
|
||||
0,
|
||||
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
_LegendDot(color: ChartColors.income, label: 'Доход'),
|
||||
SizedBox(width: 16),
|
||||
_LegendDot(color: ChartColors.expense, label: 'Расход'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY * 1.1,
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.toInt();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child:
|
||||
Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final label = rodIndex == 0 ? 'Доход' : 'Расход';
|
||||
return BarTooltipItem(
|
||||
'$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}',
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
barGroups: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6),
|
||||
BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6),
|
||||
],
|
||||
barsSpace: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendDot extends StatelessWidget {
|
||||
const _LegendDot({required this.color, required this.label});
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
final netWorthBreakdownProvider = FutureProvider.autoDispose<NetWorthBreakdown>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown();
|
||||
return r.data!;
|
||||
});
|
||||
|
||||
/// Daily net worth for the last 365 days.
|
||||
final netWorthSeriesProvider = FutureProvider.autoDispose<List<NetWorthDay>>((ref) async {
|
||||
final now = DateTime.now();
|
||||
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
|
||||
from: now.subtract(const Duration(days: 365)),
|
||||
to: now,
|
||||
);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// The current (partial) month's cashflow, or null before any data exists.
|
||||
final cashflowThisMonthProvider = FutureProvider.autoDispose<CashFlowMonth?>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
|
||||
final rows = r.data ?? const [];
|
||||
return rows.isEmpty ? null : rows.last;
|
||||
});
|
||||
|
||||
/// The last 12 months, for the income-vs-expense bar chart.
|
||||
final cashflowLast12Provider = FutureProvider.autoDispose<List<CashFlowMonth>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
final runwayProvider = FutureProvider.autoDispose<RunwayOut>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
|
||||
return r.data!;
|
||||
});
|
||||
|
||||
/// When the metric tables were last rebuilt; null before the first refresh.
|
||||
final metricsStatusProvider = FutureProvider.autoDispose<RefreshLogOut?>((ref) async {
|
||||
try {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
||||
return r.data;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
|
||||
final dataQualityProvider = FutureProvider.autoDispose<List<DataQualityRow>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// Every dashboard provider, refreshed together after a manual
|
||||
/// `POST /metrics/refresh` or a pull-to-refresh.
|
||||
void invalidateHomeProviders(WidgetRef ref) {
|
||||
ref.invalidate(netWorthBreakdownProvider);
|
||||
ref.invalidate(netWorthSeriesProvider);
|
||||
ref.invalidate(cashflowThisMonthProvider);
|
||||
ref.invalidate(cashflowLast12Provider);
|
||||
ref.invalidate(runwayProvider);
|
||||
ref.invalidate(metricsStatusProvider);
|
||||
ref.invalidate(dataQualityProvider);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/config.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
final error = await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.login(_email.text.trim(), _password.text);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(authControllerProvider).status;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: status == AuthStatus.unknown
|
||||
? const CircularProgressIndicator()
|
||||
: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('fin-tracker', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(apiBaseUrl(), style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _email,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _password,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
obscureText: true,
|
||||
onSubmitted: (_) => _busy ? null : _submit(),
|
||||
decoration: const InputDecoration(labelText: 'Пароль'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: _busy
|
||||
? const SizedBox.square(
|
||||
dimension: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Войти'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
final rulesListProvider = FutureProvider.autoDispose<List<RuleOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getRulesApi().rulesList();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// Enabled rules that matched nothing in the latest refresh.
|
||||
final rulesStaleProvider = FutureProvider.autoDispose<List<RuleOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getRulesApi().rulesStale();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) {
|
||||
final stale = ref.watch(rulesStaleProvider).valueOrNull ?? const [];
|
||||
return {for (final r in stale) r.id};
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
String ruleKindLabel(RuleKind k) => switch (k) {
|
||||
RuleKind.savings => 'Сбережения',
|
||||
RuleKind.oneOff => 'Разовая трата',
|
||||
RuleKind.category => 'Категория',
|
||||
RuleKind.payee => 'Плательщик',
|
||||
RuleKind.brokerTarget => 'Целевой брокер',
|
||||
RuleKind.ignore => 'Игнорировать',
|
||||
RuleKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
String ruleMatchTypeLabel(RuleMatchType t) => switch (t) {
|
||||
RuleMatchType.id => 'ID транзакции',
|
||||
RuleMatchType.payee => 'Плательщик',
|
||||
RuleMatchType.comment => 'Комментарий',
|
||||
RuleMatchType.category => 'Категория',
|
||||
RuleMatchType.mcc => 'MCC',
|
||||
RuleMatchType.account => 'Счёт',
|
||||
RuleMatchType.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
const _kinds = [
|
||||
RuleKind.savings,
|
||||
RuleKind.oneOff,
|
||||
RuleKind.category,
|
||||
RuleKind.payee,
|
||||
RuleKind.brokerTarget,
|
||||
RuleKind.ignore,
|
||||
];
|
||||
|
||||
const _matchTypes = [
|
||||
RuleMatchType.id,
|
||||
RuleMatchType.payee,
|
||||
RuleMatchType.comment,
|
||||
RuleMatchType.category,
|
||||
RuleMatchType.mcc,
|
||||
RuleMatchType.account,
|
||||
];
|
||||
|
||||
void _invalidateAll(WidgetRef ref) {
|
||||
ref.invalidate(rulesListProvider);
|
||||
ref.invalidate(rulesStaleProvider);
|
||||
}
|
||||
|
||||
/// Правила: list of categorisation rules, an add/edit dialog, and a manual
|
||||
/// "apply" trigger that re-runs the whole metric refresh.
|
||||
class RulesPage extends ConsumerWidget {
|
||||
const RulesPage({super.key});
|
||||
|
||||
Future<void> _apply(BuildContext context, WidgetRef ref) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getRulesApi().rulesApply();
|
||||
final ok = r.data?.error == null;
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'),
|
||||
));
|
||||
} on DioException catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
_invalidateAll(ref);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rules = ref.watch(rulesListProvider);
|
||||
final staleIds = ref.watch(staleRuleIdsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Правила'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Применить правила',
|
||||
icon: const Icon(Icons.play_circle_outline),
|
||||
onPressed: () => _apply(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(onSaved: () => _invalidateAll(ref)),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _invalidateAll(ref),
|
||||
child: AsyncValueView(
|
||||
value: rules,
|
||||
onRetry: () => ref.invalidate(rulesListProvider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'),
|
||||
],
|
||||
);
|
||||
}
|
||||
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final rule in sorted)
|
||||
_RuleTile(
|
||||
rule: rule,
|
||||
stale: staleIds.contains(rule.id),
|
||||
onChanged: () => _invalidateAll(ref),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleTile extends ConsumerStatefulWidget {
|
||||
const _RuleTile({required this.rule, required this.stale, required this.onChanged});
|
||||
|
||||
final RuleOut rule;
|
||||
final bool stale;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
ConsumerState<_RuleTile> createState() => _RuleTileState();
|
||||
}
|
||||
|
||||
class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _toggle(bool enabled) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesPatch(
|
||||
ruleId: widget.rule.id,
|
||||
rulePatch: RulePatch(enabled: enabled),
|
||||
);
|
||||
widget.onChanged();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить правило?'),
|
||||
content: Text('«${widget.rule.pattern}» — действие необратимо.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id);
|
||||
widget.onChanged();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rule = widget.rule;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(ruleKindLabel(rule.kind)),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(ruleMatchTypeLabel(rule.matchType)),
|
||||
),
|
||||
if (widget.stale) ...[
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: const Text('устарело'),
|
||||
backgroundColor: Colors.amber.withValues(alpha: 0.2),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
[
|
||||
'${rule.pattern} → ${rule.value ?? '—'}',
|
||||
'совпадений: ${rule.matchCount}',
|
||||
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
|
||||
].join(' · '),
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
|
||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleDialog extends ConsumerStatefulWidget {
|
||||
const _RuleDialog({this.existing, required this.onSaved});
|
||||
|
||||
final RuleOut? existing;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
ConsumerState<_RuleDialog> createState() => _RuleDialogState();
|
||||
}
|
||||
|
||||
class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late RuleKind _kind;
|
||||
late RuleMatchType _matchType;
|
||||
late final TextEditingController _pattern;
|
||||
late final TextEditingController _value;
|
||||
late final TextEditingController _note;
|
||||
late final TextEditingController _priority;
|
||||
late bool _enabled;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.existing;
|
||||
_kind = e?.kind ?? RuleKind.category;
|
||||
_matchType = e?.matchType ?? RuleMatchType.payee;
|
||||
_pattern = TextEditingController(text: e?.pattern ?? '');
|
||||
_value = TextEditingController(text: e?.value ?? '');
|
||||
_note = TextEditingController(text: e?.note ?? '');
|
||||
_priority = TextEditingController(text: (e?.priority ?? 100).toString());
|
||||
_enabled = e?.enabled ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pattern.dispose();
|
||||
_value.dispose();
|
||||
_note.dispose();
|
||||
_priority.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _saving = true);
|
||||
final priority = int.tryParse(_priority.text.trim()) ?? 100;
|
||||
try {
|
||||
final api = ref.read(apiProvider).getRulesApi();
|
||||
if (widget.existing == null) {
|
||||
await api.rulesCreate(
|
||||
ruleCreate: RuleCreate(
|
||||
kind: _kind,
|
||||
matchType: _matchType,
|
||||
pattern: _pattern.text.trim(),
|
||||
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
|
||||
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
|
||||
priority: priority,
|
||||
enabled: _enabled,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await api.rulesPatch(
|
||||
ruleId: widget.existing!.id,
|
||||
rulePatch: RulePatch(
|
||||
kind: _kind,
|
||||
matchType: _matchType,
|
||||
pattern: _pattern.text.trim(),
|
||||
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
|
||||
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
|
||||
priority: priority,
|
||||
enabled: _enabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
widget.onSaved();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? 'Новое правило' : 'Правило'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<RuleKind>(
|
||||
initialValue: _kind,
|
||||
decoration: const InputDecoration(labelText: 'Тип правила'),
|
||||
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))],
|
||||
onChanged: (v) => setState(() => _kind = v!),
|
||||
),
|
||||
DropdownButtonFormField<RuleMatchType>(
|
||||
initialValue: _matchType,
|
||||
decoration: const InputDecoration(labelText: 'Совпадение по'),
|
||||
items: [
|
||||
for (final t in _matchTypes)
|
||||
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _matchType = v!),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _pattern,
|
||||
decoration: const InputDecoration(labelText: 'Шаблон (pattern)'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _value,
|
||||
decoration: const InputDecoration(labelText: 'Значение (value)'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _note,
|
||||
decoration: const InputDecoration(labelText: 'Заметка'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _priority,
|
||||
decoration: const InputDecoration(labelText: 'Приоритет'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Включено'),
|
||||
value: _enabled,
|
||||
onChanged: (v) => setState(() => _enabled = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/common_providers.dart';
|
||||
import '../../core/app_info.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/config.dart';
|
||||
import '../../core/theme/theme_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
|
||||
/// Настройки: API endpoint, signed-in account, theme and logout.
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(meProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Настройки')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.dns_outlined),
|
||||
title: Text('Адрес API'),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 56, right: 16),
|
||||
title: SelectableText(apiBaseUrl()),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle_outlined),
|
||||
title: const Text('Аккаунт'),
|
||||
subtitle: AsyncValueView(
|
||||
value: me,
|
||||
data: (u) => Text(u.email),
|
||||
onRetry: () => ref.invalidate(meProvider),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.palette_outlined),
|
||||
title: Text('Тема'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('Системная'),
|
||||
icon: Icon(Icons.brightness_auto_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Светлая'),
|
||||
icon: Icon(Icons.light_mode_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Тёмная'),
|
||||
icon: Icon(Icons.dark_mode_outlined),
|
||||
),
|
||||
],
|
||||
selected: {themeMode},
|
||||
onSelectionChanged: (selection) =>
|
||||
ref.read(themeModeProvider.notifier).setMode(selection.first),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Версия приложения'),
|
||||
subtitle: Text(appVersion),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Выйти', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () => ref.read(authControllerProvider.notifier).logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class _Destination {
|
||||
const _Destination(this.path, this.icon, this.selectedIcon, this.label);
|
||||
final String path;
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
const _destinations = [
|
||||
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
|
||||
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
|
||||
_Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
|
||||
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
|
||||
_Destination(
|
||||
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
|
||||
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
|
||||
_Destination('/sync', Icons.sync_outlined, Icons.sync, 'Синк'),
|
||||
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
|
||||
];
|
||||
|
||||
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200,
|
||||
/// extended rail at 1200 and above.
|
||||
const _narrowBreakpoint = 600.0;
|
||||
const _wideBreakpoint = 1200.0;
|
||||
|
||||
/// Adaptive navigation shell around the current route: a bottom
|
||||
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
|
||||
/// extended) otherwise.
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({required this.location, required this.child, super.key});
|
||||
|
||||
final String location;
|
||||
final Widget child;
|
||||
|
||||
int get _selectedIndex {
|
||||
final i = _destinations.indexWhere((d) => d.path == location);
|
||||
return i == -1 ? 0 : i;
|
||||
}
|
||||
|
||||
void _onSelect(BuildContext context, int index) {
|
||||
if (index != _selectedIndex) context.go(_destinations[index].path);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
|
||||
if (width < _narrowBreakpoint) {
|
||||
return Scaffold(
|
||||
body: SafeArea(child: child),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (i) => _onSelect(context, i),
|
||||
destinations: [
|
||||
for (final d in _destinations)
|
||||
NavigationDestination(
|
||||
icon: Icon(d.icon),
|
||||
selectedIcon: Icon(d.selectedIcon),
|
||||
label: d.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final extended = width >= _wideBreakpoint;
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
extended: extended,
|
||||
minExtendedWidth: 220,
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (i) => _onSelect(context, i),
|
||||
labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
|
||||
destinations: [
|
||||
for (final d in _destinations)
|
||||
NavigationRailDestination(
|
||||
icon: Icon(d.icon),
|
||||
selectedIcon: Icon(d.selectedIcon),
|
||||
label: Text(d.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: SafeArea(child: child)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
|
||||
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
|
||||
|
||||
final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getSyncApi().syncStatus();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
const _pollInterval = Duration(seconds: 10);
|
||||
|
||||
/// Синк: source statuses with a manual trigger per source, and a log of the
|
||||
/// last 20 runs. Polls `/sync/status` every 10 s while this page is mounted.
|
||||
class SyncPage extends ConsumerStatefulWidget {
|
||||
const SyncPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SyncPage> createState() => _SyncPageState();
|
||||
}
|
||||
|
||||
class _SyncPageState extends ConsumerState<SyncPage> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(_pollInterval, (_) {
|
||||
if (!mounted) return;
|
||||
ref.invalidate(syncStatusProvider);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshAll() async {
|
||||
ref.invalidate(syncStatusProvider);
|
||||
ref.invalidate(syncRunsProvider);
|
||||
}
|
||||
|
||||
Future<void> _trigger(String source) async {
|
||||
try {
|
||||
await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
ref.invalidate(syncStatusProvider);
|
||||
ref.invalidate(syncRunsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(syncStatusProvider);
|
||||
final runs = ref.watch(syncRunsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Синк'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refreshAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshAll,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Источники', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: status,
|
||||
onRetry: () => ref.invalidate(syncStatusProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.cable_outlined,
|
||||
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
|
||||
)
|
||||
: Column(
|
||||
children: [for (final s in rows) _SourceCard(source: s, onTrigger: _trigger)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: runs,
|
||||
onRetry: () => ref.invalidate(syncRunsProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.')
|
||||
: Column(children: [for (final r in rows) _RunTile(run: r)]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceCard extends StatelessWidget {
|
||||
const _SourceCard({required this.source, required this.onTrigger});
|
||||
|
||||
final SourceStatus source;
|
||||
final Future<void> Function(String source) onTrigger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(source.source_, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(width: 8),
|
||||
_statusChip(context, source.lastRunStatus),
|
||||
if (source.queued) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('в очереди'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text([
|
||||
if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}',
|
||||
if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}',
|
||||
].join(' · ')),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Запустить синхронизацию',
|
||||
icon: const Icon(Icons.sync),
|
||||
onPressed: source.queued ? null : () => onTrigger(source.source_),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RunTile extends StatelessWidget {
|
||||
const _RunTile({required this.run});
|
||||
|
||||
final SyncRunOut run;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final duration = run.finishedAt?.difference(run.startedAt);
|
||||
final subtitle = [
|
||||
_dateFmt.format(run.startedAt.toLocal()),
|
||||
duration != null ? _formatDuration(duration) : 'выполняется',
|
||||
].join(' · ');
|
||||
|
||||
final counts = run.counts;
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (counts != null && counts.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final e in counts.entries)
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('${e.key}=${e.value}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (run.error != null && run.error!.isNotEmpty) {
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
title: _runTitle(context),
|
||||
subtitle: Text(subtitle),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
content,
|
||||
const SizedBox(height: 8),
|
||||
Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_runTitle(context),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
content,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runTitle(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(run.source_, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(width: 8),
|
||||
_statusChip(context, run.status),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _statusChip(BuildContext context, RunStatus? status) {
|
||||
if (status == null) {
|
||||
return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных'));
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (label, color) = switch (status) {
|
||||
RunStatus.ok => ('ok', Colors.green),
|
||||
RunStatus.error => ('error', scheme.error),
|
||||
RunStatus.running => ('running', Colors.amber.shade700),
|
||||
RunStatus.unknownDefaultOpenApi => ('неизвестно', scheme.outline),
|
||||
};
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(label),
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
labelStyle: TextStyle(color: color),
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
);
|
||||
}
|
||||
|
||||
String _shortCursor(String cursor) {
|
||||
if (cursor.length <= 20) return cursor;
|
||||
return '${cursor.substring(0, 10)}…${cursor.substring(cursor.length - 6)}';
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
if (d.inMinutes >= 1) return '${d.inMinutes} мин ${d.inSeconds % 60} с';
|
||||
return '${d.inSeconds} с';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
class TransactionsFilter {
|
||||
const TransactionsFilter({
|
||||
this.q,
|
||||
this.from,
|
||||
this.to,
|
||||
this.accountId,
|
||||
this.categoryId,
|
||||
this.flowType,
|
||||
});
|
||||
|
||||
final String? q;
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
final int? accountId;
|
||||
final int? categoryId;
|
||||
final FlowType? flowType;
|
||||
|
||||
bool get isEmpty =>
|
||||
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null;
|
||||
|
||||
TransactionsFilter copyWith({
|
||||
String? Function()? q,
|
||||
DateTime? Function()? from,
|
||||
DateTime? Function()? to,
|
||||
int? Function()? accountId,
|
||||
int? Function()? categoryId,
|
||||
FlowType? Function()? flowType,
|
||||
}) {
|
||||
return TransactionsFilter(
|
||||
q: q != null ? q() : this.q,
|
||||
from: from != null ? from() : this.from,
|
||||
to: to != null ? to() : this.to,
|
||||
accountId: accountId != null ? accountId() : this.accountId,
|
||||
categoryId: categoryId != null ? categoryId() : this.categoryId,
|
||||
flowType: flowType != null ? flowType() : this.flowType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TransactionsState {
|
||||
const TransactionsState({
|
||||
this.items = const [],
|
||||
this.page = 1,
|
||||
this.pageSize = 50,
|
||||
this.total = 0,
|
||||
this.loading = false,
|
||||
this.loadingMore = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final List<TransactionOut> items;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int total;
|
||||
final bool loading;
|
||||
final bool loadingMore;
|
||||
final Object? error;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
|
||||
TransactionsState copyWith({
|
||||
List<TransactionOut>? items,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
int? total,
|
||||
bool? loading,
|
||||
bool? loadingMore,
|
||||
Object? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return TransactionsState(
|
||||
items: items ?? this.items,
|
||||
page: page ?? this.page,
|
||||
pageSize: pageSize ?? this.pageSize,
|
||||
total: total ?? this.total,
|
||||
loading: loading ?? this.loading,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters + paginated results for Операции. `refresh()` restarts from page 1
|
||||
/// (called on build and on every filter change); `loadMore()` appends the
|
||||
/// next page for infinite scroll / the "ещё" button.
|
||||
class TransactionsController extends Notifier<TransactionsState> {
|
||||
TransactionsFilter filter = const TransactionsFilter();
|
||||
|
||||
@override
|
||||
TransactionsState build() {
|
||||
Future.microtask(refresh);
|
||||
return const TransactionsState(loading: true);
|
||||
}
|
||||
|
||||
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) {
|
||||
filter = update(filter);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = state.copyWith(loading: true, clearError: true);
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
categoryId: filter.categoryId,
|
||||
flowType: filter.flowType,
|
||||
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
|
||||
page: 1,
|
||||
pageSize: state.pageSize,
|
||||
);
|
||||
final page = r.data!;
|
||||
state = TransactionsState(
|
||||
items: page.items,
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
total: page.total,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = state.copyWith(loading: false, error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.loading || state.loadingMore || !state.hasMore) return;
|
||||
state = state.copyWith(loadingMore: true, clearError: true);
|
||||
try {
|
||||
final next = state.page + 1;
|
||||
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
categoryId: filter.categoryId,
|
||||
flowType: filter.flowType,
|
||||
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
|
||||
page: next,
|
||||
pageSize: state.pageSize,
|
||||
);
|
||||
final page = r.data!;
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
page: page.page,
|
||||
total: page.total,
|
||||
loadingMore: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = state.copyWith(loadingMore: false, error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final transactionsControllerProvider =
|
||||
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new);
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
|
||||
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
|
||||
/// its [FlowType]: the outgoing leg for expenses and transfers out, the
|
||||
/// incoming leg for income.
|
||||
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) {
|
||||
switch (t.flowType) {
|
||||
case FlowType.income:
|
||||
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
|
||||
case FlowType.expense:
|
||||
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub);
|
||||
case FlowType.internalTransfer:
|
||||
case FlowType.savingsTransfer:
|
||||
case FlowType.brokerExternalFlow:
|
||||
case FlowType.other:
|
||||
case FlowType.deleted:
|
||||
case FlowType.unknownDefaultOpenApi:
|
||||
if (t.outcome != '0' && t.outcomeCurrency != null) {
|
||||
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub);
|
||||
}
|
||||
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
|
||||
}
|
||||
}
|
||||
|
||||
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) {
|
||||
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
|
||||
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
|
||||
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
|
||||
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800),
|
||||
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple),
|
||||
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => (
|
||||
Icons.help_outline,
|
||||
scheme.outline,
|
||||
),
|
||||
};
|
||||
|
||||
/// One row of the Операции list: date, payee, category, native amount with
|
||||
/// its RUB equivalent when the currency differs, and a flow-type icon.
|
||||
class TransactionRow extends StatelessWidget {
|
||||
const TransactionRow({
|
||||
required this.transaction,
|
||||
required this.categoryName,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TransactionOut transaction;
|
||||
final String? categoryName;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = transaction;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (icon, color) = flowTypeIcon(t.flowType, scheme);
|
||||
final amount = primaryAmount(t);
|
||||
final showRub = amount.currency != 'RUB' && amount.rub != null;
|
||||
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? '—');
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(payee),
|
||||
subtitle: Text([ruDate(t.date), ?categoryName].join(' · ')),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
MoneyText(amount.amount, currency: amount.currency),
|
||||
if (showRub)
|
||||
Text(
|
||||
MoneyText.format(amount.rub!, 'RUB'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../accounts/providers.dart';
|
||||
import '../categories/providers.dart';
|
||||
import 'providers.dart';
|
||||
import 'transaction_row.dart';
|
||||
|
||||
const _flowTypes = [
|
||||
FlowType.income,
|
||||
FlowType.expense,
|
||||
FlowType.internalTransfer,
|
||||
FlowType.savingsTransfer,
|
||||
];
|
||||
|
||||
String _flowTypeLabel(FlowType t) => switch (t) {
|
||||
FlowType.income => 'Доход',
|
||||
FlowType.expense => 'Расход',
|
||||
FlowType.internalTransfer => 'Перевод',
|
||||
FlowType.savingsTransfer => 'В сбережения',
|
||||
FlowType.brokerExternalFlow => 'Брокер',
|
||||
FlowType.other => 'Прочее',
|
||||
FlowType.deleted => 'Удалено',
|
||||
FlowType.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
/// Операции: filtered, paginated transaction list with infinite scroll and a
|
||||
/// detail bottom sheet per row.
|
||||
class TransactionsPage extends ConsumerStatefulWidget {
|
||||
const TransactionsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TransactionsPage> createState() => _TransactionsPageState();
|
||||
}
|
||||
|
||||
class _TransactionsPageState extends ConsumerState<TransactionsPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(transactionsControllerProvider.notifier).loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||
ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final filter = ref.read(transactionsControllerProvider.notifier).filter;
|
||||
final now = DateTime.now();
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime.utc(2015),
|
||||
lastDate: now,
|
||||
initialDateRange: filter.from != null && filter.to != null
|
||||
? DateTimeRange(start: filter.from!, end: filter.to!)
|
||||
: null,
|
||||
helpText: 'Период',
|
||||
);
|
||||
if (picked != null) {
|
||||
ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDateRange() {
|
||||
ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(from: () => null, to: () => null),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDetail(TransactionOut t) {
|
||||
final accountNames = ref.read(accountNamesProvider);
|
||||
final categoryNames = ref.read(categoryNamesProvider);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _TransactionDetailSheet(
|
||||
transaction: t,
|
||||
accountNames: accountNames,
|
||||
categoryNames: categoryNames,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(transactionsControllerProvider);
|
||||
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter;
|
||||
final categoryNames = ref.watch(categoryNamesProvider);
|
||||
final accounts = ref.watch(accountsProvider).valueOrNull ?? const <AccountOut>[];
|
||||
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Операции')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
hintText: 'Поиск по плательщику или комментарию',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
InputChip(
|
||||
avatar: const Icon(Icons.date_range, size: 18),
|
||||
label: Text(
|
||||
controllerFilter.from != null && controllerFilter.to != null
|
||||
? '${ruDate(controllerFilter.from!)} – ${ruDate(controllerFilter.to!)}'
|
||||
: 'Период',
|
||||
),
|
||||
onPressed: _pickDateRange,
|
||||
onDeleted: controllerFilter.from != null ? _clearDateRange : null,
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<int?>(
|
||||
initialValue: controllerFilter.accountId,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все счета')),
|
||||
for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)),
|
||||
],
|
||||
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(accountId: () => v),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<int?>(
|
||||
initialValue: controllerFilter.categoryId,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Категория', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все категории')),
|
||||
for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)),
|
||||
],
|
||||
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(categoryId: () => v),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Все типы'),
|
||||
selected: controllerFilter.flowType == null,
|
||||
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(flowType: () => null),
|
||||
),
|
||||
),
|
||||
for (final ft in _flowTypes)
|
||||
ChoiceChip(
|
||||
label: Text(_flowTypeLabel(ft)),
|
||||
selected: controllerFilter.flowType == ft,
|
||||
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(flowType: () => ft),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildList(state, categoryNames)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(TransactionsState state, Map<int, String> categoryNames) {
|
||||
if (state.loading && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.error != null && state.items.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text('${state.error}', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.items.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
message: 'Операций не найдено — попробуйте изменить фильтры или выполните синхронизацию.',
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: state.items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.items.length) {
|
||||
if (!state.hasMore) return const SizedBox(height: 24);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: state.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: TextButton(
|
||||
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(),
|
||||
child: const Text('Ещё'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final t = state.items[index];
|
||||
return TransactionRow(
|
||||
transaction: t,
|
||||
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null,
|
||||
onTap: () => _showDetail(t),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransactionDetailSheet extends StatelessWidget {
|
||||
const _TransactionDetailSheet({
|
||||
required this.transaction,
|
||||
required this.accountNames,
|
||||
required this.categoryNames,
|
||||
});
|
||||
|
||||
final TransactionOut transaction;
|
||||
final Map<int, String> accountNames;
|
||||
final Map<int, String> categoryNames;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = transaction;
|
||||
final rows = <(String, String)>[
|
||||
('Дата', ruDate(t.date)),
|
||||
('Плательщик', t.payee ?? '—'),
|
||||
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!),
|
||||
('Комментарий', t.comment ?? '—'),
|
||||
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'),
|
||||
('Тип', _flowTypeLabel(t.flowType)),
|
||||
if (t.outcome != '0')
|
||||
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
|
||||
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'),
|
||||
if (t.income != '0')
|
||||
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
|
||||
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'),
|
||||
if (t.outcomeAccountId != null)
|
||||
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'),
|
||||
if (t.incomeAccountId != null)
|
||||
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'),
|
||||
if (t.mcc != null) ('MCC', '${t.mcc}'),
|
||||
('Удержание (hold)', t.hold ? 'да' : 'нет'),
|
||||
('Разовая трата', t.isOneOff ? 'да' : 'нет'),
|
||||
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
|
||||
if (t.tripId != null) ('Поездка', '#${t.tripId}'),
|
||||
('Источник (id)', t.sourceId),
|
||||
];
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Операция', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
for (final (label, value) in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const ProviderScope(child: FinTrackerApp()));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/auth/auth_controller.dart';
|
||||
import 'features/accounts/accounts_page.dart';
|
||||
import 'features/cashflow/cashflow_page.dart';
|
||||
import 'features/categories/categories_page.dart';
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/login/login_page.dart';
|
||||
import 'features/rules/rules_page.dart';
|
||||
import 'features/settings/settings_page.dart';
|
||||
import 'features/shell/app_shell.dart';
|
||||
import 'features/sync/sync_page.dart';
|
||||
import 'features/transactions/transactions_page.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final auth = ValueNotifier<AuthState>(ref.read(authControllerProvider));
|
||||
ref.listen(authControllerProvider, (_, next) => auth.value = next);
|
||||
ref.onDispose(auth.dispose);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
refreshListenable: auth,
|
||||
redirect: (context, state) {
|
||||
final status = auth.value.status;
|
||||
final atLogin = state.matchedLocation == '/login';
|
||||
return switch (status) {
|
||||
AuthStatus.unknown => atLogin ? null : '/login',
|
||||
AuthStatus.signedOut => atLogin ? null : '/login',
|
||||
AuthStatus.signedIn => atLogin ? '/' : null,
|
||||
};
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
AppShell(location: state.matchedLocation, child: child),
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const HomePage()),
|
||||
GoRoute(path: '/accounts', builder: (_, _) => const AccountsPage()),
|
||||
GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()),
|
||||
GoRoute(
|
||||
path: '/categories',
|
||||
builder: (_, state) => CategoriesPage(initialMonth: state.uri.queryParameters['month']),
|
||||
),
|
||||
GoRoute(path: '/transactions', builder: (_, _) => const TransactionsPage()),
|
||||
GoRoute(path: '/rules', builder: (_, _) => const RulesPage()),
|
||||
GoRoute(path: '/sync', builder: (_, _) => const SyncPage()),
|
||||
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
@@ -0,0 +1,128 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "fintracker_app")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "ru.fintracker.fintracker_app")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Root filesystem for cross-building.
|
||||
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
@@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
@@ -0,0 +1,25 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
@@ -0,0 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add preprocessor definitions for the application ID.
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "my_application.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
g_autoptr(MyApplication) app = my_application_new();
|
||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
}
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
|
||||
// Use a header bar when running in GNOME as this is the common style used
|
||||
// by applications and is the setup most users will be using (e.g. Ubuntu
|
||||
// desktop).
|
||||
// If running on X and not using GNOME then just use a traditional title bar
|
||||
// in case the window manager does more exotic layout, e.g. tiling.
|
||||
// If running on Wayland assume the header bar will work (may need changing
|
||||
// if future cases occur).
|
||||
gboolean use_header_bar = TRUE;
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
GdkScreen* screen = gtk_window_get_screen(window);
|
||||
if (GDK_IS_X11_SCREEN(screen)) {
|
||||
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||
use_header_bar = FALSE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "fintracker_app");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "fintracker_app");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(
|
||||
project, self->dart_entrypoint_arguments);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
GdkRGBA background_color;
|
||||
// Background defaults to black, override it here if necessary, e.g. #00000000
|
||||
// for transparent.
|
||||
gdk_rgba_parse(&background_color, "#000000");
|
||||
fl_view_set_background_color(view, &background_color);
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
// Show the window when Flutter renders.
|
||||
// Requires the view to be realized so we can start rendering.
|
||||
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
|
||||
self);
|
||||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
// Implements GApplication::local_command_line.
|
||||
static gboolean my_application_local_command_line(GApplication* application,
|
||||
gchar*** arguments,
|
||||
int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
// Strip out the first argument as it is the binary name.
|
||||
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
|
||||
|
||||
g_autoptr(GError) error = nullptr;
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Implements GApplication::startup.
|
||||
static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
// Implements GApplication::shutdown.
|
||||
static void my_application_shutdown(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application shutdown.
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
|
||||
}
|
||||
|
||||
// Implements GObject::dispose.
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
static void my_application_class_init(MyApplicationClass* klass) {
|
||||
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
|
||||
G_APPLICATION_CLASS(klass)->local_command_line =
|
||||
my_application_local_command_line;
|
||||
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
|
||||
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
|
||||
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
|
||||
}
|
||||
|
||||
static void my_application_init(MyApplication* self) {}
|
||||
|
||||
MyApplication* my_application_new() {
|
||||
// Set the program name to the application ID, which helps various systems
|
||||
// like GTK and desktop environments map this running application to its
|
||||
// corresponding .desktop file. This ensures better integration by allowing
|
||||
// the application to be recognized beyond its binary name.
|
||||
g_set_prgname(APPLICATION_ID);
|
||||
|
||||
return MY_APPLICATION(g_object_new(my_application_get_type(),
|
||||
"application-id", APPLICATION_ID, "flags",
|
||||
G_APPLICATION_NON_UNIQUE, nullptr));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef FLUTTER_MY_APPLICATION_H_
|
||||
#define FLUTTER_MY_APPLICATION_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_DECLARE_FINAL_TYPE(MyApplication,
|
||||
my_application,
|
||||
MY,
|
||||
APPLICATION,
|
||||
GtkApplication)
|
||||
|
||||
/**
|
||||
* my_application_new:
|
||||
*
|
||||
* Creates a new Flutter-based application.
|
||||
*
|
||||
* Returns: a new #MyApplication.
|
||||
*/
|
||||
MyApplication* my_application_new();
|
||||
|
||||
#endif // FLUTTER_MY_APPLICATION_H_
|
||||
@@ -0,0 +1,670 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
copy_with_extension:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: copy_with_extension
|
||||
sha256: "28b8a99384df46f5287ed87e249869fddedd299752ea83603800448e0523f00b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.1.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
decimal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: decimal
|
||||
sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.6"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.11.1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fintracker_api:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/api_client"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.69.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.3"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_util
|
||||
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.5.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
rational:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rational
|
||||
sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.28"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.7"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
state_notifier:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.3 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: fintracker_app
|
||||
description: "fin-tracker client: personal finance + investment analytics."
|
||||
publish_to: "none"
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.3
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
fintracker_api:
|
||||
path: packages/api_client
|
||||
dio: ^5.7.0
|
||||
flutter_riverpod: ^2.6.1
|
||||
go_router: ^14.6.0
|
||||
flutter_secure_storage: ^9.2.2
|
||||
decimal: ^3.0.2
|
||||
intl: ^0.20.2
|
||||
fl_chart: ^0.69.0
|
||||
shared_preferences: ^2.3.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:fintracker_app/features/shell/app_shell.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(double width) {
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 800)),
|
||||
child: MaterialApp(
|
||||
home: AppShell(location: '/', child: Container()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows a NavigationRail on a wide surface', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280));
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows a bottom NavigationBar on a narrow surface', (tester) async {
|
||||
await tester.pumpWidget(wrap(400));
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_app/core/api/api_client.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Captures whatever `onRequest` passes on, so the rewritten query can be inspected.
|
||||
class _Capture extends RequestInterceptorHandler {
|
||||
RequestOptions? seen;
|
||||
|
||||
@override
|
||||
void next(RequestOptions options) => seen = options;
|
||||
}
|
||||
|
||||
void main() {
|
||||
RequestOptions run(Map<String, dynamic> query) {
|
||||
final handler = _Capture();
|
||||
DateQueryInterceptor().onRequest(
|
||||
RequestOptions(path: '/networth/series', queryParameters: query),
|
||||
handler,
|
||||
);
|
||||
return handler.seen!;
|
||||
}
|
||||
|
||||
test('a DateTime becomes a plain YYYY-MM-DD date', () {
|
||||
// the value the generated client hands over: DateTime.now(), time and all
|
||||
final out = run({'from': DateTime(2025, 9, 18, 10, 31, 29, 84)});
|
||||
expect(out.queryParameters['from'], '2025-09-18');
|
||||
});
|
||||
|
||||
test('single-digit months and days keep two digits', () {
|
||||
final out = run({'from': DateTime(2026, 1, 5)});
|
||||
expect(out.queryParameters['from'], '2026-01-05');
|
||||
});
|
||||
|
||||
test('non-date parameters are passed through untouched', () {
|
||||
final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false});
|
||||
expect(out.queryParameters, {'page': 2, 'q': 'кофе', 'include_deleted': false});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:fintracker_app/features/home/home_page.dart';
|
||||
import 'package:fintracker_app/features/home/providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async {
|
||||
final breakdown = NetWorthBreakdown(
|
||||
accounts: const [],
|
||||
byCurrency: const {'RUB': '123456.78'},
|
||||
d: DateTime(2026, 9, 17),
|
||||
debtRub: '1000',
|
||||
investmentRub: '2000',
|
||||
liquidRub: '3000',
|
||||
missingFxCount: 0,
|
||||
savingsRub: '4000',
|
||||
totalRub: '123456.78',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
netWorthBreakdownProvider.overrideWith((ref) => breakdown),
|
||||
netWorthSeriesProvider.overrideWith((ref) => const <NetWorthDay>[]),
|
||||
cashflowThisMonthProvider.overrideWith((ref) => null),
|
||||
cashflowLast12Provider.overrideWith((ref) => const <CashFlowMonth>[]),
|
||||
runwayProvider.overrideWith((ref) => RunwayOut(
|
||||
asOf: DateTime(2026, 9, 17),
|
||||
avgBaseline3mRub: '0',
|
||||
liquidReserveRub: '0',
|
||||
runwayMonths: null,
|
||||
)),
|
||||
metricsStatusProvider.overrideWith((ref) => null),
|
||||
dataQualityProvider.overrideWith((ref) => const <DataQualityRow>[]),
|
||||
],
|
||||
child: const MaterialApp(home: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Капитал сегодня'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('123456.78', 'RUB')), findsOneWidget);
|
||||
expect(find.text('Ликвидные'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('3000', 'RUB')), findsOneWidget);
|
||||
expect(find.text('Долги'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('1000', 'RUB')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('formats a decimal-string RUB amount with ru_RU grouping', () {
|
||||
// intl's ru_RU locale data uses U+00A0 (NBSP) as both the group and
|
||||
// currency separator, so the expected string is not plain ASCII spaces.
|
||||
expect(MoneyText.format('1234.5', 'RUB'), '1 234,50 ₽');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/features/rules/providers.dart';
|
||||
import 'package:fintracker_app/features/rules/rules_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('the add-rule dialog requires a non-empty pattern', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
rulesListProvider.overrideWith((ref) => const <RuleOut>[]),
|
||||
rulesStaleProvider.overrideWith((ref) => const <RuleOut>[]),
|
||||
],
|
||||
child: const MaterialApp(home: RulesPage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Новое правило'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Обязательное поле'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:fintracker_app/features/transactions/transaction_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
TransactionOut _usdExpense() => TransactionOut(
|
||||
categoryId: 1,
|
||||
comment: null,
|
||||
date: DateTime(2026, 9, 10),
|
||||
deleted: false,
|
||||
flowType: FlowType.expense,
|
||||
hold: false,
|
||||
id: 1,
|
||||
income: '0',
|
||||
incomeAccountId: null,
|
||||
incomeCurrency: null,
|
||||
incomeRub: null,
|
||||
isOneOff: false,
|
||||
mcc: null,
|
||||
outcome: '100',
|
||||
outcomeAccountId: 7,
|
||||
outcomeCurrency: 'USD',
|
||||
outcomeRub: '9500.00',
|
||||
payee: 'Amazon.com',
|
||||
payeeCanonical: 'Amazon',
|
||||
sourceId: 'src-1',
|
||||
tags: const [],
|
||||
tripId: null,
|
||||
ts: DateTime(2026, 9, 10),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('shows a USD amount with its RUB equivalent as secondary text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TransactionRow(
|
||||
transaction: _usdExpense(),
|
||||
categoryName: 'Покупки',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Amazon'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('100', 'USD')), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('9500.00', 'RUB')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:fintracker_app/core/auth/auth_controller.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('AuthState.signedIn reflects status', () {
|
||||
expect(const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn, isTrue);
|
||||
expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse);
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 917 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="fintracker_app">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>fintracker_app</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "fintracker_app",
|
||||
"short_name": "fintracker_app",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
flutter/ephemeral/
|
||||
|
||||
# Visual Studio user-specific files.
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Visual Studio build-related files.
|
||||
x64/
|
||||
x86/
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
@@ -0,0 +1,108 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(fintracker_app LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "fintracker_app")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(VERSION 3.14...3.25)
|
||||
|
||||
# Define build configuration option.
|
||||
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
|
||||
if(IS_MULTICONFIG)
|
||||
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
|
||||
CACHE STRING "" FORCE)
|
||||
else()
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
endif()
|
||||
# Define settings for the Profile build mode.
|
||||
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
|
||||
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
|
||||
|
||||
# Use Unicode for all projects.
|
||||
add_definitions(-DUNICODE -D_UNICODE)
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_17)
|
||||
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
|
||||
target_compile_options(${TARGET} PRIVATE /EHsc)
|
||||
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# Support files are copied into place next to the executable, so that it can
|
||||
# run in place. This is done instead of making a separate bundle (as on Linux)
|
||||
# so that building and running from within Visual Studio will work.
|
||||
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
|
||||
# Make the "install" step default, as it's required to run.
|
||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
if(PLUGIN_BUNDLED_LIBRARIES)
|
||||
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
CONFIGURATIONS Profile;Release
|
||||
COMPONENT Runtime)
|
||||
@@ -0,0 +1,109 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
|
||||
|
||||
# Set fallback configurations for older versions of the flutter tool.
|
||||
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
|
||||
set(FLUTTER_TARGET_PLATFORM "windows-x64")
|
||||
endif()
|
||||
|
||||
# === Flutter Library ===
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"flutter_export.h"
|
||||
"flutter_windows.h"
|
||||
"flutter_messenger.h"
|
||||
"flutter_plugin_registrar.h"
|
||||
"flutter_texture_registrar.h"
|
||||
)
|
||||
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Wrapper ===
|
||||
list(APPEND CPP_WRAPPER_SOURCES_CORE
|
||||
"core_implementations.cc"
|
||||
"standard_codec.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
|
||||
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
|
||||
"plugin_registrar.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
|
||||
list(APPEND CPP_WRAPPER_SOURCES_APP
|
||||
"flutter_engine.cc"
|
||||
"flutter_view_controller.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
|
||||
|
||||
# Wrapper sources needed for a plugin.
|
||||
add_library(flutter_wrapper_plugin STATIC
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
)
|
||||
apply_standard_settings(flutter_wrapper_plugin)
|
||||
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON)
|
||||
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
|
||||
target_include_directories(flutter_wrapper_plugin PUBLIC
|
||||
"${WRAPPER_ROOT}/include"
|
||||
)
|
||||
add_dependencies(flutter_wrapper_plugin flutter_assemble)
|
||||
|
||||
# Wrapper sources needed for the runner.
|
||||
add_library(flutter_wrapper_app STATIC
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
)
|
||||
apply_standard_settings(flutter_wrapper_app)
|
||||
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
|
||||
target_include_directories(flutter_wrapper_app PUBLIC
|
||||
"${WRAPPER_ROOT}/include"
|
||||
)
|
||||
add_dependencies(flutter_wrapper_app flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
|
||||
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
${PHONY_OUTPUT}
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
|
||||
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter/plugin_registry.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
@@ -0,0 +1,25 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
@@ -0,0 +1,40 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME} WIN32
|
||||
"flutter_window.cpp"
|
||||
"main.cpp"
|
||||
"utils.cpp"
|
||||
"win32_window.cpp"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
"Runner.rc"
|
||||
"runner.exe.manifest"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add preprocessor definitions for the build version.
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
|
||||
|
||||
# Disable Windows macros that collide with C++ standard library functions.
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
||||
|
||||
# Add dependency libraries and include directories. Add any application-specific
|
||||
# dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
@@ -0,0 +1,121 @@
|
||||
// Microsoft Visual C++ generated resource script.
|
||||
//
|
||||
#pragma code_page(65001)
|
||||
#include "resource.h"
|
||||
|
||||
#define APSTUDIO_READONLY_SYMBOLS
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Generated from the TEXTINCLUDE 2 resource.
|
||||
//
|
||||
#include "winres.h"
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
#undef APSTUDIO_READONLY_SYMBOLS
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// English (United States) resources
|
||||
|
||||
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
|
||||
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
|
||||
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// TEXTINCLUDE
|
||||
//
|
||||
|
||||
1 TEXTINCLUDE
|
||||
BEGIN
|
||||
"resource.h\0"
|
||||
END
|
||||
|
||||
2 TEXTINCLUDE
|
||||
BEGIN
|
||||
"#include ""winres.h""\r\n"
|
||||
"\0"
|
||||
END
|
||||
|
||||
3 TEXTINCLUDE
|
||||
BEGIN
|
||||
"\r\n"
|
||||
"\0"
|
||||
END
|
||||
|
||||
#endif // APSTUDIO_INVOKED
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Icon
|
||||
//
|
||||
|
||||
// Icon with lowest ID value placed first to ensure application icon
|
||||
// remains consistent on all systems.
|
||||
IDI_APP_ICON ICON "resources\\app_icon.ico"
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Version
|
||||
//
|
||||
|
||||
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
|
||||
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
|
||||
#else
|
||||
#define VERSION_AS_NUMBER 1,0,0,0
|
||||
#endif
|
||||
|
||||
#if defined(FLUTTER_VERSION)
|
||||
#define VERSION_AS_STRING FLUTTER_VERSION
|
||||
#else
|
||||
#define VERSION_AS_STRING "1.0.0"
|
||||
#endif
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION VERSION_AS_NUMBER
|
||||
PRODUCTVERSION VERSION_AS_NUMBER
|
||||
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||
#ifdef _DEBUG
|
||||
FILEFLAGS VS_FF_DEBUG
|
||||
#else
|
||||
FILEFLAGS 0x0L
|
||||
#endif
|
||||
FILEOS VOS__WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "ru.fintracker" "\0"
|
||||
VALUE "FileDescription", "fintracker_app" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "fintracker_app" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 ru.fintracker. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "fintracker_app.exe" "\0"
|
||||
VALUE "ProductName", "fintracker_app" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1252
|
||||
END
|
||||
END
|
||||
|
||||
#endif // English (United States) resources
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
#ifndef APSTUDIO_INVOKED
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Generated from the TEXTINCLUDE 3 resource.
|
||||
//
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
#endif // not APSTUDIO_INVOKED
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "flutter_window.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
FlutterWindow::~FlutterWindow() {}
|
||||
|
||||
bool FlutterWindow::OnCreate() {
|
||||
if (!Win32Window::OnCreate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
// The size here must match the window dimensions to avoid unnecessary surface
|
||||
// creation / destruction in the startup path.
|
||||
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
|
||||
frame.right - frame.left, frame.bottom - frame.top, project_);
|
||||
// Ensure that basic setup of the controller was successful.
|
||||
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
|
||||
return false;
|
||||
}
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
this->Show();
|
||||
});
|
||||
|
||||
// Flutter can complete the first frame before the "show window" callback is
|
||||
// registered. The following call ensures a frame is pending to ensure the
|
||||
// window is shown. It is a no-op if the first frame hasn't completed yet.
|
||||
flutter_controller_->ForceRedraw();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
}
|
||||
|
||||
Win32Window::OnDestroy();
|
||||
}
|
||||
|
||||
LRESULT
|
||||
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||
if (flutter_controller_) {
|
||||
std::optional<LRESULT> result =
|
||||
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
|
||||
lparam);
|
||||
if (result) {
|
||||
return *result;
|
||||
}
|
||||
}
|
||||
|
||||
switch (message) {
|
||||
case WM_FONTCHANGE:
|
||||
flutter_controller_->engine()->ReloadSystemFonts();
|
||||
break;
|
||||
}
|
||||
|
||||
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#ifndef RUNNER_FLUTTER_WINDOW_H_
|
||||
#define RUNNER_FLUTTER_WINDOW_H_
|
||||
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "win32_window.h"
|
||||
|
||||
// A window that does nothing but host a Flutter view.
|
||||
class FlutterWindow : public Win32Window {
|
||||
public:
|
||||
// Creates a new FlutterWindow hosting a Flutter view running |project|.
|
||||
explicit FlutterWindow(const flutter::DartProject& project);
|
||||
virtual ~FlutterWindow();
|
||||
|
||||
protected:
|
||||
// Win32Window:
|
||||
bool OnCreate() override;
|
||||
void OnDestroy() override;
|
||||
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept override;
|
||||
|
||||
private:
|
||||
// The project to run.
|
||||
flutter::DartProject project_;
|
||||
|
||||
// The Flutter instance hosted by this window.
|
||||
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||
};
|
||||
|
||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||
@@ -0,0 +1,43 @@
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include "flutter_window.h"
|
||||
#include "utils.h"
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
// Attach to console when present (e.g., 'flutter run') or create a
|
||||
// new console when running with a debugger.
|
||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||
CreateAndAttachConsole();
|
||||
}
|
||||
|
||||
// Initialize COM, so that it is available for use in the library and/or
|
||||
// plugins.
|
||||
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
||||
flutter::DartProject project(L"data");
|
||||
|
||||
std::vector<std::string> command_line_arguments =
|
||||
GetCommandLineArguments();
|
||||
|
||||
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"fintracker_app", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
::MSG msg;
|
||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||
::TranslateMessage(&msg);
|
||||
::DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
::CoUninitialize();
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//{{NO_DEPENDENCIES}}
|
||||
// Microsoft Visual C++ generated include file.
|
||||
// Used by Runner.rc
|
||||
//
|
||||
#define IDI_APP_ICON 101
|
||||
|
||||
// Next default values for new objects
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 102
|
||||
#define _APS_NEXT_COMMAND_VALUE 40001
|
||||
#define _APS_NEXT_CONTROL_VALUE 1001
|
||||
#define _APS_NEXT_SYMED_VALUE 101
|
||||
#endif
|
||||
#endif
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "utils.h"
|
||||
|
||||
#include <flutter_windows.h>
|
||||
#include <io.h>
|
||||
#include <stdio.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
void CreateAndAttachConsole() {
|
||||
if (::AllocConsole()) {
|
||||
FILE *unused;
|
||||
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
|
||||
_dup2(_fileno(stdout), 1);
|
||||
}
|
||||
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
|
||||
_dup2(_fileno(stdout), 2);
|
||||
}
|
||||
std::ios::sync_with_stdio();
|
||||
FlutterDesktopResyncOutputStreams();
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> GetCommandLineArguments() {
|
||||
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
|
||||
int argc;
|
||||
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
|
||||
if (argv == nullptr) {
|
||||
return std::vector<std::string>();
|
||||
}
|
||||
|
||||
std::vector<std::string> command_line_arguments;
|
||||
|
||||
// Skip the first argument as it's the binary name.
|
||||
for (int i = 1; i < argc; i++) {
|
||||
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
|
||||
}
|
||||
|
||||
::LocalFree(argv);
|
||||
|
||||
return command_line_arguments;
|
||||
}
|
||||
|
||||
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
|
||||
if (utf16_string == nullptr) {
|
||||
return std::string();
|
||||
}
|
||||
// First, find the length of the string with a safe upper bound (CWE-126).
|
||||
// UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING.
|
||||
int input_length = static_cast<int>(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS));
|
||||
// Now use that bounded length to determine the required buffer size.
|
||||
// When an explicit length is passed, WideCharToMultiByte does not include
|
||||
// the null terminator in its returned size.
|
||||
int target_length = ::WideCharToMultiByte(
|
||||
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||
input_length, nullptr, 0, nullptr, nullptr);
|
||||
std::string utf8_string;
|
||||
if (target_length == 0 || static_cast<size_t>(target_length) > utf8_string.max_size()) {
|
||||
return utf8_string;
|
||||
}
|
||||
utf8_string.resize(target_length);
|
||||
int converted_length = ::WideCharToMultiByte(
|
||||
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||
input_length, utf8_string.data(), target_length, nullptr, nullptr);
|
||||
if (converted_length == 0) {
|
||||
return std::string();
|
||||
}
|
||||
return utf8_string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef RUNNER_UTILS_H_
|
||||
#define RUNNER_UTILS_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Creates a console for the process, and redirects stdout and stderr to
|
||||
// it for both the runner and the Flutter library.
|
||||
void CreateAndAttachConsole();
|
||||
|
||||
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
|
||||
// encoded in UTF-8. Returns an empty std::string on failure.
|
||||
std::string Utf8FromUtf16(const wchar_t* utf16_string);
|
||||
|
||||
// Gets the command line arguments passed in as a std::vector<std::string>,
|
||||
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
|
||||
std::vector<std::string> GetCommandLineArguments();
|
||||
|
||||
#endif // RUNNER_UTILS_H_
|
||||
@@ -0,0 +1,288 @@
|
||||
#include "win32_window.h"
|
||||
|
||||
#include <dwmapi.h>
|
||||
#include <flutter_windows.h>
|
||||
|
||||
#include "resource.h"
|
||||
|
||||
namespace {
|
||||
|
||||
/// Window attribute that enables dark mode window decorations.
|
||||
///
|
||||
/// Redefined in case the developer's machine has a Windows SDK older than
|
||||
/// version 10.0.22000.0.
|
||||
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||
#endif
|
||||
|
||||
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
|
||||
|
||||
/// Registry key for app theme preference.
|
||||
///
|
||||
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
|
||||
/// value indicates apps should use light mode.
|
||||
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
|
||||
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
|
||||
|
||||
// The number of Win32Window objects that currently exist.
|
||||
static int g_active_window_count = 0;
|
||||
|
||||
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
|
||||
|
||||
// Scale helper to convert logical scaler values to physical using passed in
|
||||
// scale factor
|
||||
int Scale(int source, double scale_factor) {
|
||||
return static_cast<int>(source * scale_factor);
|
||||
}
|
||||
|
||||
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
|
||||
// This API is only needed for PerMonitor V1 awareness mode.
|
||||
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
|
||||
HMODULE user32_module = LoadLibraryA("User32.dll");
|
||||
if (!user32_module) {
|
||||
return;
|
||||
}
|
||||
auto enable_non_client_dpi_scaling =
|
||||
reinterpret_cast<EnableNonClientDpiScaling*>(
|
||||
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
|
||||
if (enable_non_client_dpi_scaling != nullptr) {
|
||||
enable_non_client_dpi_scaling(hwnd);
|
||||
}
|
||||
FreeLibrary(user32_module);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Manages the Win32Window's window class registration.
|
||||
class WindowClassRegistrar {
|
||||
public:
|
||||
~WindowClassRegistrar() = default;
|
||||
|
||||
// Returns the singleton registrar instance.
|
||||
static WindowClassRegistrar* GetInstance() {
|
||||
if (!instance_) {
|
||||
instance_ = new WindowClassRegistrar();
|
||||
}
|
||||
return instance_;
|
||||
}
|
||||
|
||||
// Returns the name of the window class, registering the class if it hasn't
|
||||
// previously been registered.
|
||||
const wchar_t* GetWindowClass();
|
||||
|
||||
// Unregisters the window class. Should only be called if there are no
|
||||
// instances of the window.
|
||||
void UnregisterWindowClass();
|
||||
|
||||
private:
|
||||
WindowClassRegistrar() = default;
|
||||
|
||||
static WindowClassRegistrar* instance_;
|
||||
|
||||
bool class_registered_ = false;
|
||||
};
|
||||
|
||||
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
|
||||
|
||||
const wchar_t* WindowClassRegistrar::GetWindowClass() {
|
||||
if (!class_registered_) {
|
||||
WNDCLASS window_class{};
|
||||
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||
window_class.lpszClassName = kWindowClassName;
|
||||
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
||||
window_class.cbClsExtra = 0;
|
||||
window_class.cbWndExtra = 0;
|
||||
window_class.hInstance = GetModuleHandle(nullptr);
|
||||
window_class.hIcon =
|
||||
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
|
||||
window_class.hbrBackground = 0;
|
||||
window_class.lpszMenuName = nullptr;
|
||||
window_class.lpfnWndProc = Win32Window::WndProc;
|
||||
RegisterClass(&window_class);
|
||||
class_registered_ = true;
|
||||
}
|
||||
return kWindowClassName;
|
||||
}
|
||||
|
||||
void WindowClassRegistrar::UnregisterWindowClass() {
|
||||
UnregisterClass(kWindowClassName, nullptr);
|
||||
class_registered_ = false;
|
||||
}
|
||||
|
||||
Win32Window::Win32Window() {
|
||||
++g_active_window_count;
|
||||
}
|
||||
|
||||
Win32Window::~Win32Window() {
|
||||
--g_active_window_count;
|
||||
Destroy();
|
||||
}
|
||||
|
||||
bool Win32Window::Create(const std::wstring& title,
|
||||
const Point& origin,
|
||||
const Size& size) {
|
||||
Destroy();
|
||||
|
||||
const wchar_t* window_class =
|
||||
WindowClassRegistrar::GetInstance()->GetWindowClass();
|
||||
|
||||
const POINT target_point = {static_cast<LONG>(origin.x),
|
||||
static_cast<LONG>(origin.y)};
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
HWND window = CreateWindow(
|
||||
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
|
||||
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
|
||||
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
|
||||
nullptr, nullptr, GetModuleHandle(nullptr), this);
|
||||
|
||||
if (!window) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateTheme(window);
|
||||
|
||||
return OnCreate();
|
||||
}
|
||||
|
||||
bool Win32Window::Show() {
|
||||
return ShowWindow(window_handle_, SW_SHOWNORMAL);
|
||||
}
|
||||
|
||||
// static
|
||||
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
if (message == WM_NCCREATE) {
|
||||
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
|
||||
SetWindowLongPtr(window, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||
|
||||
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
|
||||
EnableFullDpiSupportIfAvailable(window);
|
||||
that->window_handle_ = window;
|
||||
} else if (Win32Window* that = GetThisFromHandle(window)) {
|
||||
return that->MessageHandler(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
return DefWindowProc(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
LRESULT
|
||||
Win32Window::MessageHandler(HWND hwnd,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
switch (message) {
|
||||
case WM_DESTROY:
|
||||
window_handle_ = nullptr;
|
||||
Destroy();
|
||||
if (quit_on_close_) {
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DPICHANGED: {
|
||||
auto newRectSize = reinterpret_cast<RECT*>(lparam);
|
||||
LONG newWidth = newRectSize->right - newRectSize->left;
|
||||
LONG newHeight = newRectSize->bottom - newRectSize->top;
|
||||
|
||||
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
|
||||
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
return 0;
|
||||
}
|
||||
case WM_SIZE: {
|
||||
RECT rect = GetClientArea();
|
||||
if (child_content_ != nullptr) {
|
||||
// Size and position the child window.
|
||||
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
|
||||
rect.bottom - rect.top, TRUE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_ACTIVATE:
|
||||
if (child_content_ != nullptr) {
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DWMCOLORIZATIONCOLORCHANGED:
|
||||
UpdateTheme(hwnd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DefWindowProc(window_handle_, message, wparam, lparam);
|
||||
}
|
||||
|
||||
void Win32Window::Destroy() {
|
||||
OnDestroy();
|
||||
|
||||
if (window_handle_) {
|
||||
DestroyWindow(window_handle_);
|
||||
window_handle_ = nullptr;
|
||||
}
|
||||
if (g_active_window_count == 0) {
|
||||
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
|
||||
}
|
||||
}
|
||||
|
||||
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
|
||||
return reinterpret_cast<Win32Window*>(
|
||||
GetWindowLongPtr(window, GWLP_USERDATA));
|
||||
}
|
||||
|
||||
void Win32Window::SetChildContent(HWND content) {
|
||||
child_content_ = content;
|
||||
SetParent(content, window_handle_);
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
|
||||
frame.bottom - frame.top, true);
|
||||
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
|
||||
RECT Win32Window::GetClientArea() {
|
||||
RECT frame;
|
||||
GetClientRect(window_handle_, &frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
HWND Win32Window::GetHandle() {
|
||||
return window_handle_;
|
||||
}
|
||||
|
||||
void Win32Window::SetQuitOnClose(bool quit_on_close) {
|
||||
quit_on_close_ = quit_on_close;
|
||||
}
|
||||
|
||||
bool Win32Window::OnCreate() {
|
||||
// No-op; provided for subclasses.
|
||||
return true;
|
||||
}
|
||||
|
||||
void Win32Window::OnDestroy() {
|
||||
// No-op; provided for subclasses.
|
||||
}
|
||||
|
||||
void Win32Window::UpdateTheme(HWND const window) {
|
||||
DWORD light_mode;
|
||||
DWORD light_mode_size = sizeof(light_mode);
|
||||
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
|
||||
kGetPreferredBrightnessRegValue,
|
||||
RRF_RT_REG_DWORD, nullptr, &light_mode,
|
||||
&light_mode_size);
|
||||
|
||||
if (result == ERROR_SUCCESS) {
|
||||
BOOL enable_dark_mode = light_mode == 0;
|
||||
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
&enable_dark_mode, sizeof(enable_dark_mode));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#ifndef RUNNER_WIN32_WINDOW_H_
|
||||
#define RUNNER_WIN32_WINDOW_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
|
||||
// inherited from by classes that wish to specialize with custom
|
||||
// rendering and input handling
|
||||
class Win32Window {
|
||||
public:
|
||||
struct Point {
|
||||
unsigned int x;
|
||||
unsigned int y;
|
||||
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
|
||||
};
|
||||
|
||||
struct Size {
|
||||
unsigned int width;
|
||||
unsigned int height;
|
||||
Size(unsigned int width, unsigned int height)
|
||||
: width(width), height(height) {}
|
||||
};
|
||||
|
||||
Win32Window();
|
||||
virtual ~Win32Window();
|
||||
|
||||
// Creates a win32 window with |title| that is positioned and sized using
|
||||
// |origin| and |size|. New windows are created on the default monitor. Window
|
||||
// sizes are specified to the OS in physical pixels, hence to ensure a
|
||||
// consistent size this function will scale the inputted width and height as
|
||||
// as appropriate for the default monitor. The window is invisible until
|
||||
// |Show| is called. Returns true if the window was created successfully.
|
||||
bool Create(const std::wstring& title, const Point& origin, const Size& size);
|
||||
|
||||
// Show the current window. Returns true if the window was successfully shown.
|
||||
bool Show();
|
||||
|
||||
// Release OS resources associated with window.
|
||||
void Destroy();
|
||||
|
||||
// Inserts |content| into the window tree.
|
||||
void SetChildContent(HWND content);
|
||||
|
||||
// Returns the backing Window handle to enable clients to set icon and other
|
||||
// window properties. Returns nullptr if the window has been destroyed.
|
||||
HWND GetHandle();
|
||||
|
||||
// If true, closing this window will quit the application.
|
||||
void SetQuitOnClose(bool quit_on_close);
|
||||
|
||||
// Return a RECT representing the bounds of the current client area.
|
||||
RECT GetClientArea();
|
||||
|
||||
protected:
|
||||
// Processes and route salient window messages for mouse handling,
|
||||
// size change and DPI. Delegates handling of these to member overloads that
|
||||
// inheriting classes can handle.
|
||||
virtual LRESULT MessageHandler(HWND window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept;
|
||||
|
||||
// Called when CreateAndShow is called, allowing subclass window-related
|
||||
// setup. Subclasses should return false if setup fails.
|
||||
virtual bool OnCreate();
|
||||
|
||||
// Called when Destroy is called.
|
||||
virtual void OnDestroy();
|
||||
|
||||
private:
|
||||
friend class WindowClassRegistrar;
|
||||
|
||||
// OS callback called by message pump. Handles the WM_NCCREATE message which
|
||||
// is passed when the non-client area is being created and enables automatic
|
||||
// non-client DPI scaling so that the non-client area automatically
|
||||
// responds to changes in DPI. All other messages are handled by
|
||||
// MessageHandler.
|
||||
static LRESULT CALLBACK WndProc(HWND const window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept;
|
||||
|
||||
// Retrieves a class instance pointer for |window|
|
||||
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
|
||||
|
||||
// Update the window frame's theme to match the system theme.
|
||||
static void UpdateTheme(HWND const window);
|
||||
|
||||
bool quit_on_close_ = false;
|
||||
|
||||
// window handle for top level window.
|
||||
HWND window_handle_ = nullptr;
|
||||
|
||||
// window handle for hosted content.
|
||||
HWND child_content_ = nullptr;
|
||||
};
|
||||
|
||||
#endif // RUNNER_WIN32_WINDOW_H_
|
||||