Начальный коммит

This commit is contained in:
2026-01-30 21:54:00 +07:00
parent 51de113db5
commit 3881248187
81 changed files with 5424 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
// lib/controllers/auth_controller.dart
import 'package:Stocky/services/api/api_service.dart';
import 'package:Stocky/services/local/StorageService.dart';
import 'package:get/get.dart';
class AuthController extends GetxController {
// Состояния
final _state = 'inputPhone'.obs;
final ApiService apicontr = Get.find<ApiService>();
Rx<String> phoneNumber = ''.obs;
Rx<String> verificationCode = ''.obs;
// Получаем текущее состояние
String get state => _state.value;
// Меняем состояние
void setState(String newState) {
_state.value = newState;
}
// Ввод номера
void phoneNumberInput(String value) {
phoneNumber.value = value;
}
// Проверка номера
bool get isPhoneNumberValid {
final clean = phoneNumber.value.replaceAll(RegExp(r'[^0-9]'), '');
return clean.length == 11 && clean.startsWith('7'); // или 10, если без +7
}
// Получить "чистый" номер (для отправки на сервер)
String get phoneNumberClean {
return phoneNumber.value.replaceAll(RegExp(r'[^0-9]'), '');
}
// Ввод кода
void verificationCodeInput(String value) {
verificationCode.value = value;
}
Future<void> sendCode() async {
if (phoneNumber.value.isEmpty) {
Get.snackbar('Ошибка', 'Введите номер телефона');
return;
}
if (!isPhoneNumberValid) {
Get.snackbar('Ошибка', 'Неверный номер телефона');
return;
}
await Future.delayed(const Duration(seconds: 1));
setState('inputCode');
await apicontr.registerByPhone(phoneNumberClean);
}
// Подтвердить код
Future<void> submit() async {
if (verificationCode.value.isEmpty) {
Get.snackbar('Ошибка', 'Введите код подтверждения');
return;
}
await Future.delayed(const Duration(seconds: 1));
final reg = await apicontr.loginByPhone(
phoneNumber.value,
verificationCode.value,
);
if (reg) {
Get.find<LocalStorageService>().putNumber(phoneNumber.value);
Get.snackbar('Успешно', 'Вы вошли!');
Get.offAllNamed('/home');
}
}
}

View File

@@ -0,0 +1,233 @@
import 'package:Stocky/models/task_adapter.dart';
import 'package:Stocky/models/Documents.dart';
import 'package:Stocky/services/api/api_service.dart';
import 'package:Stocky/services/api/push_notification_service.dart';
import 'package:Stocky/services/local/StorageService.dart';
import 'package:get/get.dart';
class MainController extends GetxController {
final username = ''.obs;
final tasks = <Task>[].obs;
final documents = <Document>[].obs;
final RxBool isLoading = false.obs;
final RxBool isTask = true.obs;
final RxBool isProj = true.obs;
bool _isSyncingTasks = false;
Future<void> getSettings() async {
username.value = await Get.find<LocalStorageService>().getUsername();
}
Future<void> fetchTasks() async {
if (_isSyncingTasks) return;
_isSyncingTasks = true;
try {
isLoading.value = true;
final localTasks = Get.find<LocalStorageService>().getTasks();
if (localTasks.isEmpty) {
await _fetchTasksFromServer();
} else {
await _syncTasksWithServer(localTasks);
}
final activeTasks = await Get.find<LocalStorageService>()
.getCompletedTasks();
tasks.assignAll(activeTasks);
} catch (e) {
print('Ошибка при синхронизации задач: $e');
} finally {
_isSyncingTasks = false;
isLoading.value = false;
}
}
Future<void> _syncTasksWithServer(List<Task> localTasks) async {
try {
// Шаг 1: Обрабатываем все несинхронизированные задачи
final unsyncedTasks = localTasks.where((t) => !t.isSynced).toList();
for (final task in unsyncedTasks) {
if (task.isDeletedLocally == true) {
// Удаление: пытаемся удалить на сервере
final deleted = await Get.find<ApiService>().deleteTask(
task.id.toString(),
);
if (deleted) {
Get.find<LocalStorageService>().removeTask(task.id);
} else {
// Не удалось — оставляем помеченной, попробуем позже
continue;
}
} else {
// Создание или обновление
final syncedTask = await Get.find<ApiService>().upsertTask(task);
if (syncedTask != null) {
syncedTask.isSynced = true;
syncedTask.isDeletedLocally = false;
Get.find<LocalStorageService>().updateTask(syncedTask);
}
// Если не удалось — остаётся с isSynced = false
}
}
// Шаг 2: Получаем актуальный список с сервера
final serverTasks = await Get.find<ApiService>().fetchTasks();
await Get.find<LocalStorageService>().saveTasks(serverTasks);
} catch (e) {
print('Ошибка синхронизации: $e');
// В случае ошибки — восстанавливаем локальный список
tasks.assignAll(localTasks);
}
}
Future<void> _fetchTasksFromServer() async {
try {
final serverTasks = await Get.find<ApiService>().fetchTasks();
await Get.find<LocalStorageService>().saveTasks(serverTasks);
tasks.assignAll(serverTasks);
} catch (e) {
print('Ошибка загрузки задач с сервера: $e');
// Остаёмся с пустым списком
}
}
Future<void> assignTaskToCurrentUser(Task task) async {
task.executor = username.value;
task.isSynced = false;
updateTask(task);
}
Future<void> completeTask(Task task) async {
task.isCompleted = true;
task.endTime = DateTime.now();
task.isSynced = false;
updateTask(task);
final activeTasks = await Get.find<LocalStorageService>()
.getCompletedTasks();
tasks.assignAll(activeTasks);
}
Future<void> addTask(Task task) async {
task.isSynced = false;
task.isDeletedLocally = false;
Get.find<LocalStorageService>().addTask(task);
tasks.add(task);
_syncTaskToServer(task);
}
Future<void> updateTask(Task updatedTask) async {
final index = tasks.indexWhere((t) => t.id == updatedTask.id);
if (index == -1) return;
updatedTask.isSynced = false;
updatedTask.isDeletedLocally = false;
Get.find<LocalStorageService>().updateTask(updatedTask);
tasks[index] = updatedTask;
_syncTaskToServer(updatedTask);
}
Future<void> deleteTask(Task task) async {
final index = tasks.indexWhere((t) => t.id == task.id);
if (index == -1) return;
try {
final success = await Get.find<ApiService>().deleteTask(
task.id.toString(),
);
if (success) {
Get.find<LocalStorageService>().removeTask(task.id);
tasks.removeAt(index);
} else {
// Нет связи — помечаем для отложенного удаления
_markTaskForDeletion(task);
}
} catch (e) {
print('Ошибка при удалении задачи: $e');
_markTaskForDeletion(task);
}
}
void _markTaskForDeletion(Task task) {
task.isDeletedLocally = true;
task.isSynced = false;
Get.find<LocalStorageService>().updateTask(task);
}
Future<void> _syncTaskToServer(Task task) async {
final storage = Get.find<LocalStorageService>();
if (task.isDeletedLocally == true) {
final success = await Get.find<ApiService>().deleteTask(
task.id.toString(),
);
if (success) {
storage.removeTask(task.id);
tasks.removeWhere((t) => t.id == task.id);
} else {
// Оставить помеченной — будет обработано при полной синхронизации
}
return;
}
try {
final syncedTask = await Get.find<ApiService>().upsertTask(task);
if (syncedTask == null) return;
if (syncedTask.id != task.id) {
syncedTask.isSynced = true;
syncedTask.isDeletedLocally = false;
await storage.replaceTask(task.id, syncedTask);
} else {
task.isSynced = true;
task.isDeletedLocally = false;
await storage.updateTask(syncedTask);
}
final index = tasks.indexWhere((t) => t.id == task.id);
if (index != -1) {
if (syncedTask.id != task.id) {
tasks.removeAt(index);
tasks.add(syncedTask);
} else {
tasks[index] = syncedTask;
}
}
} catch (e) {
print('Ошибка синхронизации: $e');
}
}
Task? openTask(String taskId) {
return tasks.firstWhereOrNull((task) => task.id == taskId);
}
@override
void onInit() async {
super.onInit();
Get.find<PushNotificationService>().foregroundMessageStream.listen((
message,
) {
// _handleMessageAction(message); // тот же метод, что и выше
});
getSettings();
fetchTasks();
}
void openTaskFromNotification(String taskId) {
Get.toNamed('/task', arguments: {'id': taskId});
}
}

View File

@@ -0,0 +1,32 @@
import 'package:Stocky/services/local/StorageService.dart';
import 'package:get/get.dart';
class SplashScreenController extends GetxController {
late Future<bool> _authCheck;
@override
void onInit() {
super.onInit();
_authCheck = _checkAuthentication();
}
Future<bool> _checkAuthentication() async {
final LocalStorageService settings = Get.find<LocalStorageService>();
final accessToken = settings.getAccessToken();
if (accessToken != null && accessToken.isNotEmpty) {
return true;
}
return false;
}
Future<void> navigateBasedOnAuth() async {
final isAuthenticated = await _authCheck;
if (isAuthenticated) {
Get.offAllNamed('/home');
} else {
Get.offAllNamed('/login');
}
}
}

View File

@@ -0,0 +1,148 @@
// lib/pages/login_screen.dart
import 'package:flutter/material.dart';
import 'package:Stocky/Pages/controllers/AuthController.dart';
import 'package:Stocky/Pages/widgets/phone_input_formatter.dart';
import 'package:Stocky/classes/styles.dart';
import 'package:get/get.dart';
import 'package:extended_masked_text/extended_masked_text.dart';
class LoginScreen extends StatelessWidget {
final AuthController controller = Get.put(AuthController());
LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Вход", style: AppStyles.titleLarge)),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Добро пожаловать!",
style: AppStyles.titleLarge, // ← Стиль заголовка
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
Obx(() => _buildAuthForm(controller)),
const SizedBox(height: 40),
Text(
"Мы отправим SMS для подтверждения",
style: AppStyles.description, // ← Стиль описания
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildAuthForm(AuthController controller) {
if (controller.state == 'inputPhone') {
return _buildPhoneInputForm(controller);
} else {
return _buildCodeInputForm(controller);
}
}
Widget _buildPhoneInputForm(AuthController controller) {
final phonecontrol = MaskedTextController(mask: '+7 (000) 000-00-00');
return Column(
children: [
// Ввод номера
TextField(
controller: phonecontrol,
onChanged: controller.phoneNumberInput,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: "Номер телефона",
prefixIcon: AppStyles.createPrimaryIcon(Icons.phone),
hintText: "+7 (999) 123-45-67",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey[100],
labelStyle: AppStyles.body, // ← Стиль для метки
hintStyle: AppStyles.description, // ← Стиль для подсказки
),
inputFormatters: [PhoneInputFormatter()],
),
const SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.sendCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"Отправить код",
style: AppStyles.title, // ← Стиль текста кнопки
),
),
),
],
);
}
Widget _buildCodeInputForm(AuthController controller) {
return Column(
children: [
// Ввод кода
TextField(
controller: MaskedTextController(mask: '00000'),
onChanged: controller.verificationCodeInput,
keyboardType: TextInputType.number,
obscureText: true,
decoration: InputDecoration(
labelText: "Код подтверждения",
prefixIcon: AppStyles.createPrimaryIcon(Icons.lock),
hintText: "Введите 6-значный код",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey[100],
labelStyle: AppStyles.body,
hintStyle: AppStyles.description,
),
),
const SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text("Подтвердить", style: AppStyles.title),
),
),
// Кнопка "Назад"
const SizedBox(height: 20),
TextButton(
onPressed: () {
controller.setState('inputPhone');
controller.verificationCode('');
},
child: Text(
"Вернуться к вводу номера",
style: AppStyles.link, // ← Стиль ссылки
),
),
],
);
}
}

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:Stocky/Pages/controllers/MainController.dart';
import 'package:Stocky/Pages/views/tasks_screen.dart';
import 'package:Stocky/classes/styles.dart';
import 'package:Stocky/models/task_adapter.dart';
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
import 'package:flutter_advanced_avatar/flutter_advanced_avatar.dart';
import 'package:get/get.dart';
class HomeScreen extends GetWidget<MainController> {
HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.backgroundColor,
appBar: _buildAppBar(context),
drawer: _buildDrawer(context),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: _buildFilterChips(context),
),
Obx(
() => controller.isProj.value
? SizedBox(
height: 220,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: _buildScrollableSection(
context,
child: _buildProjectsSection(context),
),
),
)
: SizedBox(),
),
// Задачи / Процессы
Padding(
padding: const EdgeInsets.all(8.0),
child: Obx(
() => controller.isTask.value
? _buildProcessSection(context)
: SizedBox(),
),
),
],
),
),
);
}
Widget _buildScrollableSection(
BuildContext context, {
required Widget child,
}) {
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(child: child);
},
);
}
AppBar _buildAppBar(BuildContext context) {
return AppBar(
title: const Text('Рабочая область'),
backgroundColor: AppColors.backgroundColor,
actions: [
IconButton(
icon: Icon(
EvaIcons.bellOutline,
size: 26,
color: Colors.blueGrey[700],
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Оповещения'),
backgroundColor: Colors.blueAccent,
),
);
},
),
],
);
}
Widget _buildFilterChips(BuildContext context) {
return Row(
children: [
Obx(
() => FilterChip(
label: const Text('Задачи'),
selected: controller.isTask.value,
onSelected: (bool selected) {
controller.isTask.value = selected;
},
selectedColor: Colors.blueAccent.withOpacity(0.1),
checkmarkColor: Colors.blueAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
const SizedBox(width: 8),
Obx(
() => FilterChip(
label: const Text('Проекты'),
selected: controller.isProj.value,
onSelected: (bool selected) {
controller.isProj.value = selected;
},
selectedColor: Colors.blueAccent.withOpacity(0.1),
checkmarkColor: Colors.blueAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
],
);
}
Widget _buildProjectsSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Projects',
style: AppStyles.sectionHeader.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(right: 8),
itemCount: 4,
itemBuilder: (context, index) =>
_buildProjectCard(context, index + 1),
),
),
],
);
}
Widget _buildProjectCard(BuildContext context, int index) {
final projectColors = [
[Colors.indigo[900]!, Colors.indigo[400]!],
[Colors.blue[900]!, Colors.blue[400]!],
[Colors.red[900]!, Colors.red[400]!],
[Colors.green[900]!, Colors.green[400]!],
];
final titles = [
'Front End Development',
'Graphic Design',
'Graphic Design',
'Graphic Design',
];
final dates = [
'September 2020',
'October 2020',
'October 2020',
'October 2020',
];
return Padding(
padding: const EdgeInsets.only(right: 12),
child: Container(
width: 160,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: projectColors[index - 1],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
shape: BoxShape.circle,
),
child: Icon(
EvaIcons.personOutline,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 8),
Text(
'Project $index',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
Text(
titles[index - 1],
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
dates[index - 1],
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12,
),
),
],
),
),
);
}
Widget _buildProcessSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Активные задачи',
style: AppStyles.sectionHeader.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Obx(() {
if (controller.tasks.isEmpty) {
return Center(child: Text('Нет активных задач'));
}
return RefreshIndicator(
onRefresh: () => controller.fetchTasks(),
child: Column(
children: controller.tasks
.map((task) => _buildProcessTaskCard(context, task))
.toList(),
),
);
}),
],
);
}
Widget _buildProcessTaskCard(BuildContext context, Task task) {
final now = DateTime.now();
final startDate = task.startTime;
String formatTime(DateTime dt) => dt.toString().substring(11, 16); // HH:mm
String formatDateTime(DateTime dt) {
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return 'Сегодня в ${formatTime(dt)}';
} else if (dt.year == now.year) {
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')} в ${formatTime(dt)}';
} else {
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} в ${formatTime(dt)}';
}
}
final int subtaskCount = task.subtasks?.length ?? 0;
final String subtaskLabel = subtaskCount == 1
? '1 пункт'
: subtaskCount < 5
? '$subtaskCount пункта'
: '$subtaskCount пунктов';
return InkWell(
onTap: () {
Get.dialog(_buildTaskExecutionDialog(task), barrierDismissible: true);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
spreadRadius: 1,
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.pink[50],
shape: BoxShape.circle,
),
child: Icon(
EvaIcons.shoppingCartOutline,
color: Colors.pink[400],
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
if (task.description.isNotEmpty == true)
Text(
task.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
),
if (subtaskCount > 0)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatDateTime(startDate),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (subtaskCount > 0)
Text(
subtaskLabel,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
],
),
),
);
}
Widget _buildDrawer(BuildContext context) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
Obx(
() => DrawerHeader(
decoration: BoxDecoration(color: Colors.blue[900]),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AdvancedAvatar(
name: controller.username.value,
autoTextSize: true,
size: 80,
statusAlignment: Alignment.topRight,
),
const SizedBox(height: 12),
Text(
controller.username.value,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('Главная'),
onTap: () {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.calendar_today),
title: const Text('Задачи'),
onTap: () {
Navigator.pop(context);
Get.to(TasksScreen()); // Переход на страницу задач
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Настройки'),
onTap: () {
Navigator.pop(context);
},
),
Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Выход'),
onTap: () {
// Логика выхода
Navigator.pop(context);
},
),
],
),
);
}
Widget _buildTaskExecutionDialog(Task task) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 60),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 500),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: StatefulBuilder(
builder: (context, setState) {
final bool isUnassigned = task.executor.isEmpty;
final bool allSubtasksDone =
task.items.isNotEmpty &&
task.items.every((item) => item.isDone);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
task.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
if (task.description.isNotEmpty)
Text(
task.description,
style: const TextStyle(
fontSize: 15,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
if (task.description.isNotEmpty) const SizedBox(height: 16),
if (task.items.isNotEmpty)
Column(
children: task.items
.map(
(item) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(item.text),
leading: item.isDone
? const Icon(
Icons.check_circle,
color: Colors.green,
)
: const Icon(
Icons.radio_button_unchecked,
color: Colors.grey,
),
onTap: () {
setState(() {
item.isDone = !item.isDone;
});
controller.updateTask(task);
},
),
)
.toList(),
),
// Автор — внизу, перед кнопками
if (task.author.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Автор: ${task.author}',
style: const TextStyle(
fontSize: 13,
color: Colors.grey,
),
),
),
const SizedBox(height: 16),
// Кнопки
Wrap(
spacing: 12,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
// Кнопка "Взять заявку"
if (isUnassigned)
ElevatedButton(
onPressed: () {
controller.assignTaskToCurrentUser(task);
// Обновляем состояние, чтобы скрыть кнопку "Взять"
// и, возможно, показать новые данные
setState(() {});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue[600],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Взять заявку',
style: TextStyle(color: Colors.white),
),
),
// Кнопка "Завершить" — появляется, если все подзадачи сделаны
if (allSubtasksDone)
ElevatedButton(
onPressed: () {
controller.completeTask(task);
Get.back(); // закрываем диалог ТОЛЬКО при завершении
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[600],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Завершить',
style: TextStyle(color: Colors.white),
),
),
// Кнопка "Закрыть" — всегда доступна
OutlinedButton(
onPressed: Get.back,
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Закрыть'),
),
],
),
],
);
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:Stocky/Pages/controllers/SplashScreenController.dart';
import 'package:get/get.dart';
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
// Автоматически создаём и привязываем контроллер к этому экрану
final controller = Get.put(SplashScreenController());
// Запускаем логику навигации один раз, когда виджет встроен в дерево
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.navigateBasedOnAuth();
});
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 20),
Text('Загрузка...', style: TextStyle(fontSize: 18)),
],
),
),
);
}
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:Stocky/Pages/controllers/MainController.dart';
import 'package:Stocky/classes/styles.dart';
import 'package:Stocky/models/task_adapter.dart';
import 'package:get/get.dart';
class TaskFormScreen extends StatefulWidget {
final Task? task;
const TaskFormScreen({super.key, this.task});
@override
State<TaskFormScreen> createState() => _TaskFormScreenState();
}
class _TaskFormScreenState extends State<TaskFormScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _itemsController = TextEditingController();
DateTime? _selectedDate;
List<Subtask> _items = [];
String _newItem = '';
@override
void initState() {
super.initState();
if (widget.task != null) {
_titleController.text = widget.task!.title;
_descriptionController.text = widget.task!.description;
_selectedDate = widget.task!.startTime;
_items = List.from(widget.task!.items);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.task != null ? 'Редактировать задачу' : 'Новая задача',
),
actions: [
TextButton(
onPressed: _saveTask,
child: const Text('Сохранить', style: TextStyle(fontSize: 16)),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: ListView(
children: [
// Дата создания
ListTile(
title: Text(
_selectedDate != null
? 'Дата: ${_selectedDate!.day}.${_selectedDate!.month}.${_selectedDate!.year}'
: 'Выберите дату',
),
trailing: const Icon(Icons.calendar_today),
onTap: _selectDate,
),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Название задачи',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Введите название задачи';
}
return null;
},
),
const SizedBox(height: 16),
// Описание (опционально)
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Описание',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 3,
),
const SizedBox(height: 16),
// Пункты задачи (чеклист)
Text('Пункты задачи', style: AppStyles.titleSmall),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _itemsController,
decoration: const InputDecoration(
labelText: 'Добавить пункт',
border: OutlineInputBorder(),
),
onChanged: (value) {
_newItem = value;
},
),
),
IconButton(icon: const Icon(Icons.add), onPressed: _addItem),
],
),
const SizedBox(height: 8),
// Список пунктов
if (_items.isNotEmpty)
..._items.map(
(item) => Card(
child: ListTile(
title: Text(item.text),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeItem(item),
),
),
),
),
],
),
),
),
);
}
void _selectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2101),
);
if (picked != null) {
setState(() {
_selectedDate = picked;
});
}
}
void _addItem() {
if (_newItem.trim().isNotEmpty) {
setState(() {
_items.add(
Subtask(
id: DateTime.now().microsecondsSinceEpoch.toString(),
text: _newItem.trim(),
isDone: false,
),
);
_itemsController.clear();
_newItem = '';
});
}
}
void _removeItem(Subtask item) {
setState(() {
_items.remove(item);
});
}
void _saveTask() {
if (_formKey.currentState!.validate()) {
final mainController = Get.find<MainController>();
String generateId() {
return DateTime.now().microsecondsSinceEpoch.toString();
}
final task = Task(
id: widget.task?.id ?? generateId(),
title: _titleController.text,
description: _descriptionController.text.trim(),
startTime: _selectedDate ?? DateTime.now(),
items: List.from(_items),
author: mainController.username.value,
executor: mainController.username.value,
isSynced: false,
isDeletedLocally: false,
);
try {
if (widget.task == null) {
mainController.addTask(task);
} else {
mainController.updateTask(task);
}
Get.back();
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Ошибка сохранения: $e')));
}
}
}
}

View File

@@ -0,0 +1,313 @@
// lib/Pages/views/tasks_screen.dart
import 'package:flutter/material.dart';
import 'package:Stocky/Pages/controllers/MainController.dart';
import 'package:Stocky/Pages/views/task_screen.dart';
import 'package:Stocky/classes/styles.dart';
import 'package:Stocky/models/task_adapter.dart';
import 'package:eva_icons_flutter/eva_icons_flutter.dart';
import 'package:swipeable_tile/swipeable_tile.dart';
import 'package:get/get.dart';
class TasksScreen extends GetWidget<MainController> {
const TasksScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.backgroundColor,
appBar: _buildAppBar(context),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDateSelector(context),
const SizedBox(height: 20),
_buildTasksList(context),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// После создания — синхронизация не нужна: addTask сам всё делает
Get.to(() => const TaskFormScreen());
},
child: const Icon(Icons.add),
),
);
}
AppBar _buildAppBar(BuildContext context) {
return AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text(
'Sep 2020',
style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold),
),
actions: [
IconButton(
icon: Icon(EvaIcons.search, size: 26, color: Colors.blueGrey[700]),
onPressed: () {},
),
],
);
}
Widget _buildDateSelector(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue[900],
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(7, (index) {
final dayName = [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
][index];
final dayNumber = 20 + index;
return GestureDetector(
onTap: () {
// Логика выбора даты (опционально)
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: dayNumber == 21 ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
dayName,
style: TextStyle(
fontSize: 10,
color: dayNumber == 21 ? Colors.blue[900] : Colors.white,
),
),
const SizedBox(height: 2),
Text(
dayNumber.toString(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: dayNumber == 21 ? Colors.blue[900] : Colors.white,
),
),
],
),
),
);
}),
),
);
}
Widget _buildTasksList(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tasks',
style: AppStyles.sectionHeader.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Obx(() {
// 🔑 ФИЛЬТРУЕМ: скрываем задачи, помеченные на удаление
final visibleTasks = controller.tasks
.where((task) => task.isDeletedLocally != true)
.toList();
if (visibleTasks.isEmpty) {
return Center(
child: Text(
'No tasks found',
style: TextStyle(color: Colors.grey[600]),
),
);
}
return Column(
children: visibleTasks
.map((task) => _buildTaskCard(context, task))
.toList(),
);
}),
],
);
}
Widget _buildTaskCard(BuildContext context, Task task) {
final startDate = task.startTime;
String formatTime(DateTime dt) => dt.toString().substring(11, 16); // HH:mm
return SwipeableTile.card(
color: Colors.white,
shadow: BoxShadow(
color: Colors.black.withOpacity(0.35),
blurRadius: 4,
offset: const Offset(2, 2),
),
key: ValueKey(task.id), // ← лучше использовать id, а не UniqueKey()
horizontalPadding: 8,
verticalPadding: 8,
child: InkWell(
onTap: () {
Get.dialog(_buildTaskExecutionDialog(task), barrierDismissible: true);
},
onLongPress: () => Get.to(
TaskFormScreen(task: task),
), // ← синхронизация происходит автоматически через updateTask
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.pink[50],
shape: BoxShape.circle,
),
child: Icon(
_getTaskIcon('meeting'),
size: 24,
color: Colors.pink[400],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
task.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${formatTime(startDate)} to ${formatTime(startDate.add(Duration(hours: 2)))}',
style: const TextStyle(
fontSize: 13,
color: Colors.grey,
height: 1.4,
),
),
],
),
),
IconButton(
onPressed: () {
// Доп. действия (например, меню)
},
icon: const Icon(Icons.more_vert, color: Colors.grey),
),
],
),
),
),
onSwiped: (direction) => controller.deleteTask(task),
backgroundBuilder: (context, direction, progress) {
return Container(
color: Colors.redAccent,
child: const Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'Удалить',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
);
},
);
}
IconData _getTaskIcon(String category) {
switch (category.toLowerCase()) {
case 'client call':
return Icons.phone_rounded;
default:
return EvaIcons.shoppingCartOutline;
}
}
Widget _buildTaskExecutionDialog(Task task) {
// Здесь можно реализовать логику выполнения подзадач
// Пока оставим как есть (без сохранения изменений)
return Material(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(30.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
task.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(task.description, style: const TextStyle(fontSize: 16)),
const SizedBox(height: 16),
...task.items.map(
(item) => ListTile(
title: Text(item.text),
leading: item.isDone
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.radio_button_unchecked),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: Get.back, child: const Text('Закрыть')),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/services.dart';
class PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// Если ввод пустой — возвращаем его как есть
if (newValue.text.isEmpty) {
return newValue;
}
// Убираем все нецифровые символы из ввода
String input = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
// Ограничиваем длину — максимум 11 цифр (без +7)
if (input.length > 11) {
input = input.substring(0, 11);
}
// Применяем маску
String formatted = _applyPhoneMask(input);
// Возвращаем отформатированное значение, сохраняя курсор
return newValue.copyWith(
text: formatted,
selection: TextSelection.collapsed(
offset: _getCursorOffset(input, formatted),
),
);
}
// Применяет маску +7 (XXX) XXX-XX-XX
String _applyPhoneMask(String digits) {
if (digits.isEmpty) return '';
if (digits.length == 1) return '+7';
if (digits.length == 2) return '+7 (${'$digits'.substring(1, 2)})';
if (digits.length == 3) return '+7 (${digits.substring(1)})';
if (digits.length == 4) return '+7 (${digits.substring(1, 4)})';
if (digits.length == 5)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 5)}';
if (digits.length == 6)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 6)}';
if (digits.length == 7)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 7)}';
if (digits.length == 8)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 7)}-${digits.substring(7, 8)}';
if (digits.length == 9)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 7)}-${digits.substring(7, 9)}';
if (digits.length == 10)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9, 10)}';
if (digits.length == 11)
return '+7 (${digits.substring(1, 4)}) ${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9, 11)}';
return digits;
}
// Вычисляет новую позицию курсора после форматирования
int _getCursorOffset(String oldDigits, String formatted) {
// Если ввод был короче — курсор должен быть в конце
if (oldDigits.length >= formatted.length) {
return formatted.length;
}
// Сопоставляем позиции: сколько цифр было до текущей позиции
int cursorPosition = 0;
for (int i = 0; i < oldDigits.length; i++) {
String before = _applyPhoneMask(oldDigits.substring(0, i + 1));
String after = _applyPhoneMask(oldDigits.substring(0, i + 2));
if (after.length > before.length) {
cursorPosition++;
}
}
return cursorPosition;
}
}