Clean up: move docs to Obsidian vault, remove outdated md files
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user