Clean up: move docs to Obsidian vault, remove outdated md files

This commit is contained in:
2026-04-21 15:54:20 +05:00
parent 448403d7ee
commit 4c50036172
22 changed files with 1295 additions and 2869 deletions
+321
View File
@@ -0,0 +1,321 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
/// Модель файла из Gitea Contents API
class GiteaFile {
final String name;
final String path;
final String type; // 'file' | 'dir'
final String? sha;
final String? content; // base64 encoded
final int? size;
final String? downloadUrl;
GiteaFile({
required this.name,
required this.path,
required this.type,
this.sha,
this.content,
this.size,
this.downloadUrl,
});
factory GiteaFile.fromJson(Map<String, dynamic> json) {
return GiteaFile(
name: json['name'] ?? '',
path: json['path'] ?? '',
type: json['type'] ?? 'file',
sha: json['sha'],
content: json['content'],
size: json['size'],
downloadUrl: json['download_url'],
);
}
/// Декодирует base64 content (Gitea возвращает base64 с переносами)
String? get decodedContent {
if (content == null || content!.isEmpty) return null;
try {
final clean = content!.replaceAll('\n', '');
return utf8.decode(base64Decode(clean));
} catch (e) {
return null;
}
}
}
/// Сервис прямой синхронизации с Gitea REST API
/// Используется в мобильном приложении вместо Obsidian Git plugin
class GiteaSyncService {
static final GiteaSyncService _instance = GiteaSyncService._internal();
factory GiteaSyncService() => _instance;
GiteaSyncService._internal();
String? _baseUrl;
String? _token;
String? _owner;
String? _repo;
static const String _prefsBaseUrlKey = 'gitea_base_url';
static const String _prefsTokenKey = 'gitea_token';
static const String _prefsOwnerKey = 'gitea_owner';
static const String _prefsRepoKey = 'gitea_repo';
/// Инициализация из SharedPreferences
Future<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
_baseUrl = prefs.getString(_prefsBaseUrlKey);
_token = prefs.getString(_prefsTokenKey);
_owner = prefs.getString(_prefsOwnerKey);
_repo = prefs.getString(_prefsRepoKey);
}
/// Настройка подключения
Future<void> configure({
required String baseUrl,
required String token,
required String owner,
required String repo,
}) async {
_baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), '');
_token = token;
_owner = owner;
_repo = repo;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsBaseUrlKey, _baseUrl!);
await prefs.setString(_prefsTokenKey, _token!);
await prefs.setString(_prefsOwnerKey, _owner!);
await prefs.setString(_prefsRepoKey, _repo!);
}
bool get isConfigured =>
_baseUrl != null && _token != null && _owner != null && _repo != null;
// ==================== AUTH / TEST ====================
/// Проверка соединения и валидности токена
Future<bool> testConnection() async {
if (!isConfigured) return false;
try {
final response = await _get('/api/v1/user');
return response.statusCode == 200;
} catch (e) {
return false;
}
}
/// Получить информацию о текущем пользователе
Future<Map<String, dynamic>?> getCurrentUser() async {
final response = await _get('/api/v1/user');
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
return null;
}
// ==================== REPOSITORY CONTENTS ====================
/// Получить список файлов в директории репозитория
Future<List<GiteaFile>> listFiles({
String path = '',
String branch = 'main',
}) async {
final response = await _get(
'/api/v1/repos/$_owner/$_repo/contents/$path?ref=$branch',
);
if (response.statusCode != 200) {
throw Exception(
'Failed to list files: ${response.statusCode} ${response.body}');
}
final List<dynamic> data = jsonDecode(response.body);
return data
.map((e) => GiteaFile.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Получить содержимое одного файла
Future<GiteaFile> getFile({
required String path,
String branch = 'main',
}) async {
final response = await _get(
'/api/v1/repos/$_owner/$_repo/contents/$path?ref=$branch',
);
if (response.statusCode != 200) {
throw Exception(
'Failed to get file: ${response.statusCode} ${response.body}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return GiteaFile.fromJson(data);
}
/// Обновить/создать файл в репозитории
Future<void> updateFile({
required String path,
required String content,
required String commitMessage,
String branch = 'main',
String? authorName,
String? authorEmail,
}) async {
// Получаем текущий sha если файл существует
String? currentSha;
try {
final existing = await getFile(path: path, branch: branch);
currentSha = existing.sha;
} catch (_) {
// файл не существует — создаём новый
}
final body = <String, dynamic>{
'content': base64Encode(utf8.encode(content)),
'message': commitMessage,
'branch': branch,
};
if (currentSha != null) {
body['sha'] = currentSha;
}
if (authorName != null && authorEmail != null) {
body['author'] = {
'name': authorName,
'email': authorEmail,
};
}
final response = await _put(
'/api/v1/repos/$_owner/$_repo/contents/$path',
body: body,
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
'Failed to update file: ${response.statusCode} ${response.body}');
}
}
/// Удалить файл из репозитория
Future<void> deleteFile({
required String path,
required String commitMessage,
String branch = 'main',
}) async {
final existing = await getFile(path: path, branch: branch);
final response = await _delete(
'/api/v1/repos/$_owner/$_repo/contents/$path',
body: {
'message': commitMessage,
'sha': existing.sha,
'branch': branch,
},
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
'Failed to delete file: ${response.statusCode} ${response.body}');
}
}
// ==================== GIT OPERATIONS ====================
/// Получить дерево файлов (рекурсивно)
Future<List<GiteaFile>> getTree({
String branch = 'main',
bool recursive = true,
}) async {
final response = await _get(
'/api/v1/repos/$_owner/$_repo/git/trees/$branch?recursive=${recursive ? 1 : 0}',
);
if (response.statusCode != 200) {
throw Exception('Failed to get tree: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final List<dynamic> tree = data['tree'] ?? [];
return tree
.where((e) => e['type'] == 'blob')
.map((e) => GiteaFile(
name: e['path'].toString().split('/').last,
path: e['path'] ?? '',
type: 'file',
sha: e['sha'],
size: e['size'],
))
.toList();
}
/// Получить последний коммит ветки
Future<Map<String, dynamic>?> getLatestCommit(
{String branch = 'main'}) async {
final response = await _get(
'/api/v1/repos/$_owner/$_repo/commits?sha=$branch&limit=1',
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
if (data.isNotEmpty) return data.first as Map<String, dynamic>;
}
return null;
}
/// Получить историю коммитов
Future<List<Map<String, dynamic>>> getCommits({
String branch = 'main',
int page = 1,
int limit = 30,
}) async {
final response = await _get(
'/api/v1/repos/$_owner/$_repo/commits?sha=$branch&page=$page&limit=$limit',
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data.cast<Map<String, dynamic>>();
}
return [];
}
// ==================== HELPERS ====================
Map<String, String> _getHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': 'token $_token',
};
}
Future<http.Response> _get(String path) async {
final uri = Uri.parse('$_baseUrl$path');
return http.get(uri, headers: _getHeaders());
}
Future<http.Response> _put(String path,
{required Map<String, dynamic> body}) async {
final uri = Uri.parse('$_baseUrl$path');
return http.put(
uri,
headers: _getHeaders(),
body: jsonEncode(body),
);
}
Future<http.Response> _delete(String path,
{required Map<String, dynamic> body}) async {
final uri = Uri.parse('$_baseUrl$path');
final request = http.Request('DELETE', uri)
..headers.addAll(_getHeaders())
..body = jsonEncode(body);
return http.Client().send(request).then((r) => http.Response.fromStream(r));
}
}
+194
View File
@@ -0,0 +1,194 @@
import 'package:uuid/uuid.dart';
import '../models/mind_map_model.dart';
/// Парсер Markdown-файлов в MindMapNode структуру
///
/// Ожидаемая структура markdown:
/// ```markdown
/// ---
/// title: Название карты
/// tags: [crm, project]
/// ---
///
/// # Корневой узел
/// Описание корневого узла
///
/// ## Дочерний узел 1
/// Описание
///
/// ### Внук 1
///
/// ## Дочерний узел 2
/// ```
class MarkdownMindmapParser {
static const Uuid _uuid = Uuid();
/// Парсит markdown строку в дерево MindMapNode
/// Возвращает корневой узел + все дочерние
static List<MindMapNode> parse(String markdown, String projectId) {
final lines = markdown.split('\n');
final nodes = <MindMapNode>[];
final now = DateTime.now();
String? currentParentId;
final Map<int, String> levelToParentId = {};
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
// Пропускаем frontmatter
if (trimmed == '---') continue;
if (trimmed.startsWith('title:') ||
trimmed.startsWith('tags:') ||
trimmed.startsWith('date:')) {
continue;
}
// Определяем уровень заголовка
final headerMatch = _headerRegex.firstMatch(trimmed);
if (headerMatch != null) {
final hashes = headerMatch.group(1)!;
final level = hashes.length;
final title = trimmed.substring(hashes.length).trim();
final id = _uuid.v4();
// Определяем parentId на основе уровня
if (level == 1) {
// Корневой узел
currentParentId = null;
levelToParentId.clear();
} else {
// Ищем ближайший родитель с уровнем меньше текущего
String? parentId;
for (int l = level - 1; l >= 1; l--) {
if (levelToParentId.containsKey(l)) {
parentId = levelToParentId[l];
break;
}
}
currentParentId = parentId;
}
levelToParentId[level] = id;
nodes.add(MindMapNode(
id: id,
projectId: projectId,
parentId: currentParentId,
level: level,
title: title,
color: _levelColor(level),
createdAt: now,
updatedAt: now,
));
}
}
// Если нет ни одного заголовка — создаём один корневой узел с текстом
if (nodes.isEmpty && markdown.trim().isNotEmpty) {
final firstLine = markdown.trim().split('\n').first.trim();
nodes.add(MindMapNode(
id: _uuid.v4(),
projectId: projectId,
parentId: null,
level: 0,
title: firstLine.length > 50 ? '${firstLine.substring(0, 50)}...' : firstLine,
color: _levelColor(0),
createdAt: now,
updatedAt: now,
));
}
return nodes;
}
/// Конвертирует дерево MindMapNode обратно в Markdown
static String toMarkdown(List<MindMapNode> nodes, {String? title}) {
if (nodes.isEmpty) return '';
final buffer = StringBuffer();
// Frontmatter
buffer.writeln('---');
buffer.writeln('title: ${title ?? 'Mindmap'}');
buffer.writeln('date: ${DateTime.now().toIso8601String()}');
buffer.writeln('---');
buffer.writeln();
// Сортируем по уровню, затем по порядку
final sorted = List<MindMapNode>.from(nodes)
..sort((a, b) {
final levelCompare = a.level.compareTo(b.level);
if (levelCompare != 0) return levelCompare;
return a.createdAt.compareTo(b.createdAt);
});
for (final node in sorted) {
final hashes = '#' * node.level.clamp(1, 6);
buffer.writeln('$hashes ${node.title}');
if (node.description != null && node.description!.isNotEmpty) {
buffer.writeln(node.description);
}
buffer.writeln();
}
return buffer.toString().trim();
}
/// Извлекает plain text описание из markdown (без заголовков)
static String? extractDescription(String markdown) {
final lines = markdown.split('\n');
final buffer = StringBuffer();
bool inFrontmatter = false;
for (final line in lines) {
final trimmed = line.trim();
if (trimmed == '---') {
inFrontmatter = !inFrontmatter;
continue;
}
if (inFrontmatter) continue;
if (trimmed.startsWith('#')) continue;
if (trimmed.isNotEmpty) {
buffer.writeln(trimmed);
}
}
final result = buffer.toString().trim();
return result.isEmpty ? null : result;
}
/// Извлекает wiki-ссылки [[Link]] из текста
static List<String> extractWikiLinks(String text) {
final matches = _wikiLinkRegex.allMatches(text);
return matches.map((m) => m.group(1)!).toList();
}
// ==================== PRIVATE ====================
static final RegExp _headerRegex = RegExp(r'^(#{1,6})\s+(.+)$');
static final RegExp _wikiLinkRegex = RegExp(r'\[\[([^\]]+)\]\]');
static String _levelColor(int level) {
switch (level) {
case 0:
return '#E53935'; // red
case 1:
return '#1E88E5'; // blue
case 2:
return '#43A047'; // green
case 3:
return '#FB8C00'; // orange
case 4:
return '#8E24AA'; // purple
case 5:
return '#00ACC1'; // cyan
default:
return '#78909C'; // grey
}
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'dart:convert';
import 'package:hive/hive.dart';
/// Hive модель для кэшированного mindmap-файла
class CachedMindmap {
final String path;
final String content;
final String? sha;
final DateTime lastSync;
final DateTime lastModified;
final String branch;
CachedMindmap({
required this.path,
required this.content,
this.sha,
required this.lastSync,
required this.lastModified,
this.branch = 'main',
});
Map<String, dynamic> toJson() => {
'path': path,
'content': content,
'sha': sha,
'lastSync': lastSync.toIso8601String(),
'lastModified': lastModified.toIso8601String(),
'branch': branch,
};
factory CachedMindmap.fromJson(Map<String, dynamic> json) {
return CachedMindmap(
path: json['path'] ?? '',
content: json['content'] ?? '',
sha: json['sha'],
lastSync:
DateTime.parse(json['lastSync'] ?? DateTime.now().toIso8601String()),
lastModified: DateTime.parse(
json['lastModified'] ?? DateTime.now().toIso8601String()),
branch: json['branch'] ?? 'main',
);
}
}
/// Оффлайн репозиторий для mindmaps
/// Хранит кэш файлов из Gitea и очередь изменений для синхронизации
class OfflineMindmapRepository {
static const String _boxName = 'mindmaps_cache';
static const String _queueBoxName = 'mindmaps_queue';
static const String _metaKey = '_meta_sync_state';
Box<String>? _box;
Box<String>? _queueBox;
Future<void> initialize() async {
_box = await Hive.openBox<String>(_boxName);
_queueBox = await Hive.openBox<String>(_queueBoxName);
}
bool get isInitialized => _box != null && _box!.isOpen;
// ==================== CACHE ====================
/// Сохранить файл в кэш
Future<void> cacheMindmap(CachedMindmap mindmap) async {
if (!isInitialized) throw Exception('Repository not initialized');
await _box!.put(mindmap.path, jsonEncode(mindmap.toJson()));
}
/// Получить файл из кэша
CachedMindmap? getMindmap(String path) {
if (!isInitialized) return null;
final raw = _box!.get(path);
if (raw == null) return null;
try {
return CachedMindmap.fromJson(jsonDecode(raw));
} catch (_) {
return null;
}
}
/// Получить все кэшированные mindmaps
List<CachedMindmap> getAllMindmaps() {
if (!isInitialized) return [];
return _box!.values
.map((raw) {
try {
return CachedMindmap.fromJson(jsonDecode(raw));
} catch (_) {
return null;
}
})
.whereType<CachedMindmap>()
.toList();
}
/// Удалить файл из кэша
Future<void> deleteMindmap(String path) async {
if (!isInitialized) return;
await _box!.delete(path);
}
/// Очистить весь кэш
Future<void> clearCache() async {
if (!isInitialized) return;
await _box!.clear();
}
// ==================== SYNC QUEUE ====================
/// Добавить изменение в очередь (для offline → online sync)
Future<void> queueChange({
required String path,
required String content,
required String operation, // 'create' | 'update' | 'delete'
String? commitMessage,
}) async {
if (!isInitialized) throw Exception('Repository not initialized');
final entry = {
'path': path,
'content': content,
'operation': operation,
'commitMessage': commitMessage ?? 'Mobile sync: $path',
'timestamp': DateTime.now().toIso8601String(),
};
await _queueBox!.put(
'${DateTime.now().millisecondsSinceEpoch}_$path', jsonEncode(entry));
}
/// Получить все изменения из очереди
List<Map<String, dynamic>> getPendingChanges() {
if (!isInitialized) return [];
return _queueBox!.values
.map((raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
})
.whereType<Map<String, dynamic>>()
.toList()
..sort((a, b) => (a['timestamp'] ?? '').compareTo(b['timestamp'] ?? ''));
}
/// Удалить выполненное изменение из очереди
Future<void> removeQueuedChange(String key) async {
if (!isInitialized) return;
await _queueBox!.delete(key);
}
/// Очистить очередь
Future<void> clearQueue() async {
if (!isInitialized) return;
await _queueBox!.clear();
}
bool get hasPendingChanges => (_queueBox?.length ?? 0) > 0;
// ==================== SYNC STATE ====================
/// Сохранить время последней успешной синхронизации
Future<void> setLastSync(DateTime time) async {
if (!isInitialized) return;
await _box!.put(_metaKey, jsonEncode({'lastSync': time.toIso8601String()}));
}
/// Получить время последней синхронизации
DateTime? getLastSync() {
if (!isInitialized) return null;
final raw = _box!.get(_metaKey);
if (raw == null) return null;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
return DateTime.tryParse(data['lastSync'] ?? '');
} catch (_) {
return null;
}
}
}
+12
View File
@@ -17,6 +17,8 @@ import '../screens/admin/projects_management_screen.dart';
import '../screens/admin/notifications_settings_screen.dart';
import '../screens/admin/mind_map_admin_screen.dart';
import '../screens/admin/ai_stats_screen.dart';
import '../screens/gitea_sync_settings_screen.dart';
import '../screens/gitea_mindmaps_list_screen.dart';
import '../providers/auth_provider.dart';
// Глобальный ключ для навигации
@@ -81,6 +83,16 @@ class AppRouter {
return MindMapSettingsScreen(projectId: projectId);
},
),
GoRoute(
path: 'gitea-sync',
name: 'gitea-sync',
builder: (context, state) => const GiteaSyncSettingsScreen(),
),
GoRoute(
path: 'gitea-mindmaps',
name: 'gitea-mindmaps',
builder: (context, state) => const GiteaMindmapsListScreen(),
),
],
),
GoRoute(
+6
View File
@@ -4,6 +4,8 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'core/firebase_config.dart';
import 'core/local_storage_service.dart';
import 'core/notification_service.dart';
import 'core/gitea_sync_service.dart';
import 'core/offline_mindmap_repository.dart';
import 'core/theme.dart';
import 'core/router.dart';
@@ -22,6 +24,10 @@ void main() async {
// Инициализация уведомлений
await notificationService.initialize();
// Инициализация Gitea синхронизации (mindmaps offline cache)
await GiteaSyncService().initialize();
await OfflineMindmapRepository().initialize();
runApp(
const ProviderScope(
child: NanaxeCrmApp(),
+228
View File
@@ -0,0 +1,228 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/gitea_sync_service.dart';
import '../core/offline_mindmap_repository.dart';
import '../core/markdown_mindmap_parser.dart';
import '../models/mind_map_model.dart';
/// Provider для GiteaSyncService
final giteaSyncServiceProvider = Provider<GiteaSyncService>((ref) {
return GiteaSyncService();
});
/// Provider для OfflineMindmapRepository
final offlineMindmapRepoProvider = Provider<OfflineMindmapRepository>((ref) {
return OfflineMindmapRepository();
});
/// State для mindmap синхронизации
class MindmapSyncState {
final List<GiteaFile> files;
final List<CachedMindmap> cachedMindmaps;
final bool isLoading;
final bool isSyncing;
final String? error;
final bool isConfigured;
MindmapSyncState({
this.files = const [],
this.cachedMindmaps = const [],
this.isLoading = false,
this.isSyncing = false,
this.error,
this.isConfigured = false,
});
MindmapSyncState copyWith({
List<GiteaFile>? files,
List<CachedMindmap>? cachedMindmaps,
bool? isLoading,
bool? isSyncing,
String? error,
bool? isConfigured,
}) {
return MindmapSyncState(
files: files ?? this.files,
cachedMindmaps: cachedMindmaps ?? this.cachedMindmaps,
isLoading: isLoading ?? this.isLoading,
isSyncing: isSyncing ?? this.isSyncing,
error: error ?? this.error,
isConfigured: isConfigured ?? this.isConfigured,
);
}
}
/// Notifier для управления синхронизацией mindmaps
class MindmapSyncNotifier extends StateNotifier<MindmapSyncState> {
final GiteaSyncService _gitea;
final OfflineMindmapRepository _repo;
MindmapSyncNotifier(this._gitea, this._repo) : super(MindmapSyncState());
/// Инициализация сервиса
Future<void> initialize() async {
await _repo.initialize();
await _gitea.initialize();
state = state.copyWith(isConfigured: _gitea.isConfigured);
if (state.isConfigured) {
await loadCachedMindmaps();
}
}
/// Настройка Gitea
Future<void> configure({
required String baseUrl,
required String token,
required String owner,
required String repo,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
await _gitea.configure(
baseUrl: baseUrl,
token: token,
owner: owner,
repo: repo,
);
final connected = await _gitea.testConnection();
if (!connected) {
throw Exception('Failed to connect to Gitea. Check token and URL.');
}
state = state.copyWith(isConfigured: true, isLoading: false);
await syncFromGitea();
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Загрузить mindmaps из кэша
Future<void> loadCachedMindmaps() async {
final cached = _repo.getAllMindmaps();
state = state.copyWith(cachedMindmaps: cached);
}
/// Полная синхронизация с Gitea (download)
Future<void> syncFromGitea({String branch = 'main'}) async {
if (!state.isConfigured) return;
state = state.copyWith(isSyncing: true, error: null);
try {
// Получаем дерево файлов
final files = await _gitea.getTree(branch: branch);
final mindmapFiles = files.where((f) =>
f.path.endsWith('.md') || f.path.endsWith('.canvas')).toList();
// Загружаем каждый файл
for (final file in mindmapFiles) {
try {
final giteaFile = await _gitea.getFile(path: file.path, branch: branch);
final content = giteaFile.decodedContent;
if (content != null) {
await _repo.cacheMindmap(CachedMindmap(
path: file.path,
content: content,
sha: giteaFile.sha,
lastSync: DateTime.now(),
lastModified: DateTime.now(),
branch: branch,
));
}
} catch (e) {
// Логируем ошибку но продолжаем
print('Failed to sync ${file.path}: $e');
}
}
await loadCachedMindmaps();
state = state.copyWith(isSyncing: false);
} catch (e) {
state = state.copyWith(isSyncing: false, error: e.toString());
}
}
/// Парсить markdown-кэш в MindMapNode
List<MindMapNode>? parseMindmap(String path) {
final cached = _repo.getMindmap(path);
if (cached == null) return null;
return MarkdownMindmapParser.parse(
cached.content,
path.replaceAll('/', '_'),
);
}
/// Сохранить изменения mindmap и запушить в Gitea
Future<void> saveAndPush({
required String path,
required List<MindMapNode> nodes,
String? title,
String branch = 'main',
}) async {
if (!state.isConfigured) {
throw Exception('Gitea not configured');
}
final markdown = MarkdownMindmapParser.toMarkdown(nodes, title: title);
// Сохраняем локально
await _repo.cacheMindmap(CachedMindmap(
path: path,
content: markdown,
lastSync: DateTime.now(),
lastModified: DateTime.now(),
branch: branch,
));
// Пушим в Gitea
try {
await _gitea.updateFile(
path: path,
content: markdown,
commitMessage: 'mobile: update $path',
branch: branch,
authorName: 'NANAXE Mobile',
authorEmail: 'mobile@nanaxe.local',
);
} catch (e) {
// Если нет сети — кладём в очередь
await _repo.queueChange(
path: path,
content: markdown,
operation: 'update',
commitMessage: 'mobile: update $path',
);
}
await loadCachedMindmaps();
}
/// Отправить накопленные offline-изменения
Future<void> pushPendingChanges({String branch = 'main'}) async {
if (!state.isConfigured) return;
final pending = _repo.getPendingChanges();
for (final change in pending) {
try {
await _gitea.updateFile(
path: change['path'],
content: change['content'],
commitMessage: change['commitMessage'] ?? 'Mobile sync',
branch: branch,
);
// Удаляем из очереди по timestamp_path
final key = '${change['timestamp']}_${change['path']}';
await _repo.removeQueuedChange(key);
} catch (e) {
print('Failed to push queued change: $e');
}
}
}
}
/// StateNotifierProvider для mindmap синхронизации
final mindmapSyncProvider =
StateNotifierProvider<MindmapSyncNotifier, MindmapSyncState>((ref) {
final gitea = ref.watch(giteaSyncServiceProvider);
final repo = ref.watch(offlineMindmapRepoProvider);
return MindmapSyncNotifier(gitea, repo);
});
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/gitea_sync_provider.dart';
import '../core/markdown_mindmap_parser.dart';
class GiteaMindmapsListScreen extends ConsumerWidget {
const GiteaMindmapsListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(mindmapSyncProvider);
final notifier = ref.read(mindmapSyncProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Mindmaps из Gitea'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => context.pushNamed('gitea-sync'),
),
if (state.isConfigured)
IconButton(
icon: state.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.sync),
onPressed: state.isSyncing ? null : () => notifier.syncFromGitea(),
),
],
),
body: _buildBody(context, state, notifier),
);
}
Widget _buildBody(BuildContext context, MindmapSyncState state, MindmapSyncNotifier notifier) {
if (!state.isConfigured) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('Gitea не настроен'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.pushNamed('gitea-sync'),
child: const Text('Настроить'),
),
],
),
);
}
if (state.isSyncing && state.cachedMindmaps.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.cachedMindmaps.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Ошибка: ${state.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => notifier.syncFromGitea(),
child: const Text('Повторить'),
),
],
),
);
}
final mindmaps = state.cachedMindmaps;
if (mindmaps.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('Нет mindmaps в кэше'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => notifier.syncFromGitea(),
child: const Text('Синхронизировать с Gitea'),
),
],
),
);
}
return ListView.builder(
itemCount: mindmaps.length,
itemBuilder: (context, index) {
final mm = mindmaps[index];
final nodes = MarkdownMindmapParser.parse(mm.content, mm.path);
final rootTitle = nodes.isNotEmpty ? nodes.first.title : mm.path;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getColorForPath(mm.path),
child: const Icon(Icons.account_tree, color: Colors.white),
),
title: Text(rootTitle),
subtitle: Text(
'${mm.path}\n'
'${mm.lastSync.toLocal().toString().split('.').first}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (mm.path.endsWith('.canvas'))
const Chip(
label: Text('Canvas', style: TextStyle(fontSize: 10)),
padding: EdgeInsets.zero,
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
// TODO: открыть просмотр mindmap
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Открытие ${mm.path}...')),
);
},
),
);
},
);
}
Color _getColorForPath(String path) {
if (path.endsWith('.canvas')) return Colors.purple;
if (path.endsWith('.md')) return Colors.blue;
return Colors.grey;
}
}
+203
View File
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/gitea_sync_provider.dart';
class GiteaSyncSettingsScreen extends ConsumerStatefulWidget {
const GiteaSyncSettingsScreen({super.key});
@override
ConsumerState<GiteaSyncSettingsScreen> createState() => _GiteaSyncSettingsScreenState();
}
class _GiteaSyncSettingsScreenState extends ConsumerState<GiteaSyncSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _urlCtrl = TextEditingController(text: 'http://git.a-crm.controlsystem.kz');
final _tokenCtrl = TextEditingController();
final _ownerCtrl = TextEditingController(text: 'Alexyz');
final _repoCtrl = TextEditingController(text: 'CMMNANAXE-CRM');
bool _testing = false;
@override
void dispose() {
_urlCtrl.dispose();
_tokenCtrl.dispose();
_ownerCtrl.dispose();
_repoCtrl.dispose();
super.dispose();
}
Future<void> _testAndSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _testing = true);
final notifier = ref.read(mindmapSyncProvider.notifier);
try {
await notifier.configure(
baseUrl: _urlCtrl.text.trim(),
token: _tokenCtrl.text.trim(),
owner: _ownerCtrl.text.trim(),
repo: _repoCtrl.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Подключение к Gitea установлено')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _testing = false);
}
}
Future<void> _syncNow() async {
setState(() => _testing = true);
final notifier = ref.read(mindmapSyncProvider.notifier);
try {
await notifier.syncFromGitea();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Синхронизация завершена')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка синхронизации: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _testing = false);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(mindmapSyncProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Синхронизация с Gitea'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: ListView(
children: [
// Статус
Card(
child: ListTile(
leading: Icon(
state.isConfigured ? Icons.cloud_done : Icons.cloud_off,
color: state.isConfigured ? Colors.green : Colors.grey,
),
title: Text(
state.isConfigured ? 'Подключено к Gitea' : 'Не подключено',
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: state.isSyncing
? const Text('Синхронизация...')
: state.error != null
? Text('Ошибка: ${state.error}')
: const Text('Настройте подключение ниже'),
),
),
const SizedBox(height: 16),
// URL
TextFormField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'Gitea URL',
hintText: 'http://git.a-crm.controlsystem.kz',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.link),
),
validator: (v) => v == null || v.isEmpty ? 'Укажите URL' : null,
),
const SizedBox(height: 12),
// Token
TextFormField(
controller: _tokenCtrl,
decoration: const InputDecoration(
labelText: 'Access Token',
hintText: 'Ваш токен из Gitea (Settings → Applications)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.key),
),
obscureText: true,
validator: (v) => v == null || v.isEmpty ? 'Укажите токен' : null,
),
const SizedBox(height: 12),
// Owner
TextFormField(
controller: _ownerCtrl,
decoration: const InputDecoration(
labelText: 'Owner (пользователь/организация)',
hintText: 'Alexyz',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
validator: (v) => v == null || v.isEmpty ? 'Укажите владельца' : null,
),
const SizedBox(height: 12),
// Repo
TextFormField(
controller: _repoCtrl,
decoration: const InputDecoration(
labelText: 'Repository',
hintText: 'CMMNANAXE-CRM',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.folder),
),
validator: (v) => v == null || v.isEmpty ? 'Укажите репозиторий' : null,
),
const SizedBox(height: 24),
// Кнопки
ElevatedButton.icon(
onPressed: _testing ? null : _testAndSave,
icon: _testing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Сохранить и проверить'),
),
const SizedBox(height: 12),
if (state.isConfigured)
ElevatedButton.icon(
onPressed: _testing ? null : _syncNow,
icon: const Icon(Icons.sync),
label: const Text('Синхронизировать сейчас'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
],
),
),
),
);
}
}