diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..79f7eca
--- /dev/null
+++ b/app/.gitignore
@@ -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/
diff --git a/app/.metadata b/app/.metadata
new file mode 100644
index 0000000..87df591
--- /dev/null
+++ b/app/.metadata
@@ -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'
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000..86fc42f
--- /dev/null
+++ b/app/README.md
@@ -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`.
diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml
new file mode 100644
index 0000000..12c6d5a
--- /dev/null
+++ b/app/analysis_options.yaml
@@ -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
diff --git a/app/android/.gitignore b/app/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/app/android/.gitignore
@@ -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
diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
new file mode 100644
index 0000000..aa0458d
--- /dev/null
+++ b/app/android/app/build.gradle.kts
@@ -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 = "../.."
+}
diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/app/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..28b3b32
--- /dev/null
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/kotlin/ru/fintracker/fintracker_app/MainActivity.kt b/app/android/app/src/main/kotlin/ru/fintracker/fintracker_app/MainActivity.kt
new file mode 100644
index 0000000..a88f7b7
--- /dev/null
+++ b/app/android/app/src/main/kotlin/ru/fintracker/fintracker_app/MainActivity.kt
@@ -0,0 +1,5 @@
+package ru.fintracker.fintracker_app
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/app/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/app/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/app/android/build.gradle.kts
@@ -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("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/app/android/gradle.properties b/app/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/app/android/gradle.properties
@@ -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
diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a20f2c4
--- /dev/null
+++ b/app/android/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts
new file mode 100644
index 0000000..b28021a
--- /dev/null
+++ b/app/android/settings.gradle.kts
@@ -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")
diff --git a/app/lib/app.dart b/app/lib/app.dart
new file mode 100644
index 0000000..01cef7a
--- /dev/null
+++ b/app/lib/app.dart
@@ -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,
+ );
+ }
+}
diff --git a/app/lib/core/api/api_client.dart b/app/lib/core/api/api_client.dart
new file mode 100644
index 0000000..3eec9d5
--- /dev/null
+++ b/app/lib/core/api/api_client.dart
@@ -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((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 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);
+ }
+ }
+}
diff --git a/app/lib/core/api/common_providers.dart b/app/lib/core/api/common_providers.dart
new file mode 100644
index 0000000..c6915af
--- /dev/null
+++ b/app/lib/core/api/common_providers.dart
@@ -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((ref) async {
+ final r = await ref.watch(apiProvider).getAuthApi().authMe();
+ return r.data!;
+});
+
+/// Backend health, used by Обзор.
+final healthProvider = FutureProvider((ref) async {
+ final r = await ref.watch(apiProvider).getHealthApi().healthCheck();
+ return r.data!;
+});
diff --git a/app/lib/core/app_info.dart b/app/lib/core/app_info.dart
new file mode 100644
index 0000000..721e640
--- /dev/null
+++ b/app/lib/core/app_info.dart
@@ -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';
diff --git a/app/lib/core/auth/auth_controller.dart b/app/lib/core/auth/auth_controller.dart
new file mode 100644
index 0000000..72fa907
--- /dev/null
+++ b/app/lib/core/auth/auth_controller.dart
@@ -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());
+
+/// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse.
+final authDioProvider = Provider(
+ (_) => Dio(BaseOptions(
+ baseUrl: apiBaseUrl(),
+ connectTimeout: const Duration(seconds: 10),
+ receiveTimeout: const Duration(seconds: 20),
+ )),
+);
+
+final authControllerProvider = NotifierProvider(AuthController.new);
+
+class AuthController extends Notifier {
+ @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 _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 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 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 _accept(TokenPair pair) async {
+ await _store.writeRefresh(pair.refreshToken);
+ state = AuthState(AuthStatus.signedIn, accessToken: pair.accessToken, email: state.email);
+ }
+
+ Future 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 ?? 'Ошибка запроса',
+ };
+}
diff --git a/app/lib/core/auth/token_store.dart b/app/lib/core/auth/token_store.dart
new file mode 100644
index 0000000..1d86ee0
--- /dev/null
+++ b/app/lib/core/auth/token_store.dart
@@ -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 readRefresh() => _storage.read(key: _refreshKey);
+ Future writeRefresh(String token) => _storage.write(key: _refreshKey, value: token);
+ Future clear() => _storage.delete(key: _refreshKey);
+}
diff --git a/app/lib/core/config.dart b/app/lib/core/config.dart
new file mode 100644
index 0000000..73f03c1
--- /dev/null
+++ b/app/lib/core/config.dart
@@ -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';
+}
diff --git a/app/lib/core/theme/chart_colors.dart b/app/lib/core/theme/chart_colors.dart
new file mode 100644
index 0000000..a55ef20
--- /dev/null
+++ b/app/lib/core/theme/chart_colors.dart
@@ -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;
+}
diff --git a/app/lib/core/theme/theme_controller.dart b/app/lib/core/theme/theme_controller.dart
new file mode 100644
index 0000000..caa845f
--- /dev/null
+++ b/app/lib/core/theme/theme_controller.dart
@@ -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.new);
+
+/// Persists the chosen [ThemeMode] to this device via `shared_preferences`.
+class ThemeModeController extends Notifier {
+ @override
+ ThemeMode build() {
+ Future.microtask(_restore);
+ return ThemeMode.system;
+ }
+
+ Future _restore() async {
+ final prefs = await SharedPreferences.getInstance();
+ final saved = prefs.getString(_prefsKey);
+ state = ThemeMode.values.asNameMap()[saved] ?? ThemeMode.system;
+ }
+
+ Future setMode(ThemeMode mode) async {
+ state = mode;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_prefsKey, mode.name);
+ }
+}
diff --git a/app/lib/core/utils/ru_date.dart b/app/lib/core/utils/ru_date.dart
new file mode 100644
index 0000000..b9cf46f
--- /dev/null
+++ b/app/lib/core/utils/ru_date.dart
@@ -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}';
diff --git a/app/lib/core/widgets/async_value_view.dart b/app/lib/core/widgets/async_value_view.dart
new file mode 100644
index 0000000..4de54c9
--- /dev/null
+++ b/app/lib/core/widgets/async_value_view.dart
@@ -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 extends StatelessWidget {
+ const AsyncValueView({required this.value, required this.data, this.onRetry, super.key});
+
+ final AsyncValue 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();
+}
diff --git a/app/lib/core/widgets/empty_state.dart b/app/lib/core/widgets/empty_state.dart
new file mode 100644
index 0000000..a637858
--- /dev/null
+++ b/app/lib/core/widgets/empty_state.dart
@@ -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,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/core/widgets/money_text.dart b/app/lib/core/widgets/money_text.dart
new file mode 100644
index 0000000..5001c62
--- /dev/null
+++ b/app/lib/core/widgets/money_text.dart
@@ -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);
+ }
+}
diff --git a/app/lib/features/accounts/accounts_page.dart b/app/lib/features/accounts/accounts_page.dart
new file mode 100644
index 0000000..4cc636b
--- /dev/null
+++ b/app/lib/features/accounts/accounts_page.dart
@@ -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 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 _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(
+ 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)),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/features/accounts/providers.dart b/app/lib/features/accounts/providers.dart
new file mode 100644
index 0000000..1f2e25f
--- /dev/null
+++ b/app/lib/features/accounts/providers.dart
@@ -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>((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