dockerfiles
Some checks are pending
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
Some checks are pending
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
This commit is contained in:
parent
7295a15759
commit
1e424031dd
79 changed files with 158 additions and 3137 deletions
37
mnemo_cards_admin/Dockerfile
Normal file
37
mnemo_cards_admin/Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# --- Build stage ------------------------------------------------------------
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for dependency caching
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Verify build output
|
||||
RUN ls -la dist/ && test -f dist/index.html || (echo "ERROR: index.html not found!" && exit 1)
|
||||
|
||||
# --- Runtime stage ----------------------------------------------------------
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# Install wget for healthcheck
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built app
|
||||
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
||||
|
||||
# Verify files were copied
|
||||
RUN ls -la /usr/share/nginx/html/ && test -f /usr/share/nginx/html/index.html || (echo "ERROR: index.html not copied!" && exit 1)
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1
|
||||
41
mnemo_cards_admin/nginx.conf
Normal file
41
mnemo_cards_admin/nginx.conf
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
|
||||
|
||||
# Security headers (enhanced for admin panel)
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "private, must-revalidate";
|
||||
}
|
||||
|
||||
# React SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Security - deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,332 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:mnemo_cards/domain/router/dialogs.dart';
|
||||
import 'package:universal_io/io.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class EditCard {
|
||||
GameCardDto gameCardDto;
|
||||
AdminApi adminApi;
|
||||
bool imageChanged = false;
|
||||
|
||||
EditCard(GameCardDto? cardDto, this.adminApi)
|
||||
: gameCardDto =
|
||||
cardDto ??
|
||||
const GameCardDto(
|
||||
id: -1,
|
||||
image: null,
|
||||
mnemo: null,
|
||||
original: null,
|
||||
translation: null,
|
||||
transcription: null,
|
||||
transcriptionMnemo: null,
|
||||
imageBack: null,
|
||||
back: null,
|
||||
);
|
||||
|
||||
void _saveCard() async {
|
||||
final dto = imageChanged ? gameCardDto : gameCardDto.copyWith.image(null);
|
||||
final _progress = ValueNotifier<(int, int)>((0, 1));
|
||||
final _dialogCompleter = Completer();
|
||||
showInfoDialog(
|
||||
'',
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _progress,
|
||||
builder: (c, v, _) => Text(
|
||||
(v.$1 == v.$2 || v.$2 < 0)
|
||||
? 'Waiting for response...'
|
||||
: 'Uploading ${v.$1}/${v.$2}...',
|
||||
),
|
||||
),
|
||||
completer: _dialogCompleter,
|
||||
);
|
||||
final result = await adminApi.addCard(
|
||||
dto,
|
||||
progressCallback: (a, b) => _progress.value = (a, b),
|
||||
);
|
||||
_dialogCompleter.complete();
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(
|
||||
scaffoldKey.currentContext!,
|
||||
).showSnackBar(SnackBar(content: Text('ERROR')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
scaffoldKey.currentContext!,
|
||||
).showSnackBar(SnackBar(content: Text('Success!!!')));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteCard() async {
|
||||
final result = await adminApi.deleteCard(gameCardDto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(
|
||||
scaffoldKey.currentContext!,
|
||||
).showSnackBar(SnackBar(content: Text('ERROR')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
scaffoldKey.currentContext!,
|
||||
).showSnackBar(SnackBar(content: Text('Success!!!')));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void addCard(BuildContext context) {
|
||||
final theme = Theme.of(context).textTheme;
|
||||
|
||||
final TextEditingController originalController = TextEditingController(
|
||||
text: gameCardDto.original,
|
||||
);
|
||||
final TextEditingController translationController = TextEditingController(
|
||||
text: gameCardDto.translation,
|
||||
);
|
||||
final TextEditingController transcriptionController = TextEditingController(
|
||||
text: gameCardDto.transcription,
|
||||
);
|
||||
final TextEditingController transcriptionMnemoController =
|
||||
TextEditingController(text: gameCardDto.transcriptionMnemo);
|
||||
final TextEditingController mnemoController = TextEditingController(
|
||||
text: gameCardDto.mnemo,
|
||||
);
|
||||
final TextEditingController backController = TextEditingController(
|
||||
text: gameCardDto.back,
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) {
|
||||
return Center(
|
||||
child: Scaffold(
|
||||
body: Material(
|
||||
child: ListView(
|
||||
children: [
|
||||
Header(
|
||||
'${gameCardDto.id}',
|
||||
hasPopButton: true,
|
||||
trail: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _saveCard,
|
||||
icon: Icon(Icons.save),
|
||||
),
|
||||
if (gameCardDto.id >= 0)
|
||||
GestureDetector(
|
||||
onLongPress: _deleteCard,
|
||||
child: Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: originalController,
|
||||
onChanged: (v) {
|
||||
gameCardDto = gameCardDto.copyWith(
|
||||
original: v,
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Original (cerdo)',
|
||||
),
|
||||
maxLines: 1,
|
||||
style: theme.displayMedium?.copyWith(
|
||||
fontSize:
|
||||
theme.displayMedium!.fontSize!,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: translationController,
|
||||
onChanged: (v) {
|
||||
gameCardDto = gameCardDto.copyWith(
|
||||
translation: v,
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Translation (свинья)',
|
||||
),
|
||||
maxLines: 1,
|
||||
style: theme.headlineSmall?.copyWith(
|
||||
color: theme.headlineSmall!.color!
|
||||
.withOpacity(0.5),
|
||||
fontSize:
|
||||
theme.headlineSmall!.fontSize!,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextField(
|
||||
controller: transcriptionController,
|
||||
onChanged: (v) {
|
||||
gameCardDto = gameCardDto
|
||||
.copyWith(transcription: v);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Transcription',
|
||||
),
|
||||
maxLines: 1,
|
||||
style: theme.headlineSmall
|
||||
?.copyWith(
|
||||
color: theme
|
||||
.headlineSmall!
|
||||
.color!,
|
||||
fontSize: theme
|
||||
.headlineSmall!
|
||||
.fontSize!,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.0),
|
||||
Flexible(
|
||||
child: TextField(
|
||||
controller:
|
||||
transcriptionMnemoController,
|
||||
onChanged: (v) {
|
||||
gameCardDto = gameCardDto
|
||||
.copyWith(
|
||||
transcriptionMnemo: v,
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Transcription mnemo',
|
||||
),
|
||||
maxLines: 1,
|
||||
style: theme.headlineSmall
|
||||
?.copyWith(
|
||||
color: theme
|
||||
.headlineSmall!
|
||||
.color!,
|
||||
fontSize: theme
|
||||
.headlineSmall!
|
||||
.fontSize!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width - 20,
|
||||
height:
|
||||
MediaQuery.of(context).size.width - 20,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
FilePickerResult? result =
|
||||
await FilePicker.platform.pickFiles();
|
||||
if (result != null &&
|
||||
result.files.isNotEmpty) {
|
||||
imageChanged = true;
|
||||
final file = result.files.first;
|
||||
final encoded = base64.normalize(
|
||||
base64.encode(
|
||||
File(file.path!).readAsBytesSync(),
|
||||
),
|
||||
);
|
||||
gameCardDto = gameCardDto.copyWith(
|
||||
image: encoded,
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
child: gameCardDto.image == null
|
||||
? Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.white,
|
||||
child: Icon(
|
||||
Icons.image,
|
||||
color: Colors.grey,
|
||||
),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
Image(
|
||||
key: ValueKey(base64),
|
||||
image: MemoryImage(
|
||||
base64.decode(
|
||||
gameCardDto.image!,
|
||||
),
|
||||
),
|
||||
gaplessPlayback: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
Positioned(
|
||||
top: 8.0,
|
||||
right: 8.0,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
gameCardDto = gameCardDto
|
||||
.copyWith(image: null);
|
||||
setState(() {});
|
||||
},
|
||||
icon: Icon(Icons.delete),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: mnemoController,
|
||||
onChanged: (v) {
|
||||
gameCardDto = gameCardDto.copyWith(
|
||||
mnemo: v,
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Mnemo ([свинья] с сердечком)',
|
||||
),
|
||||
maxLines: 1,
|
||||
style: theme.headlineSmall?.copyWith(
|
||||
fontSize: theme.headlineSmall!.fontSize!,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: backController,
|
||||
onChanged: (v) {
|
||||
gameCardDto = gameCardDto.copyWith(back: v);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Задняя сторона',
|
||||
),
|
||||
maxLines: 1,
|
||||
style: theme.headlineSmall?.copyWith(
|
||||
fontSize: theme.headlineSmall!.fontSize!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,383 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../main.dart';
|
||||
import 'date_param.dart';
|
||||
import 'enum_param.dart';
|
||||
import 'int_param.dart';
|
||||
import 'text_param.dart';
|
||||
|
||||
class AddDiscountCampaign {
|
||||
late DiscountCampaignDto campaignDto;
|
||||
late AdminApi adminApi;
|
||||
List<DiscountCampaignDto> _campaigns = [];
|
||||
|
||||
AddDiscountCampaign() {
|
||||
this.adminApi = getIt.get<AdminApi>();
|
||||
_init(null);
|
||||
}
|
||||
|
||||
void _init(DiscountCampaignDto? dto) {
|
||||
final now = DateTime.now();
|
||||
campaignDto = dto ??
|
||||
DiscountCampaignDto(
|
||||
start: now,
|
||||
finish: now.add(Duration(days: 30)),
|
||||
status: DiscountCampaignStatus.disabled,
|
||||
name: null,
|
||||
tags: [],
|
||||
discounts: [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _loadCampaigns() async {
|
||||
try {
|
||||
_campaigns = await adminApi.getDiscountCampaigns();
|
||||
} catch (e, s) {
|
||||
log('Error', error: e, stackTrace: s);
|
||||
print('Error $e');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _savePromoCodeCampaign() async {
|
||||
for (final d in campaignDto.discounts) {
|
||||
final products = d.products.map((p) => p.toProduct());
|
||||
d.products.replaceRange(0, products.length, products);
|
||||
}
|
||||
|
||||
final result = await adminApi.addDiscountCampaign(campaignDto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteDiscountCampaign() async {
|
||||
if (campaignDto.id == null) return;
|
||||
final result =
|
||||
await adminApi.deleteDiscountCampaign(campaignDto.id.toString());
|
||||
if (result != null) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void addCampaign(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) {
|
||||
return FutureBuilder(
|
||||
future: _loadCampaigns(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data != true) {
|
||||
return Center(
|
||||
child: Material(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Scaffold(
|
||||
body: Material(
|
||||
child: StatefulBuilder(builder: (context, setCampaign) {
|
||||
return ListView(
|
||||
children: [
|
||||
Header(
|
||||
'${campaignDto.id}',
|
||||
hasPopButton: true,
|
||||
trail: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _savePromoCodeCampaign,
|
||||
icon: Icon(Icons.save),
|
||||
),
|
||||
if (campaignDto.id != null)
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(id: null);
|
||||
_savePromoCodeCampaign();
|
||||
},
|
||||
icon: Icon(Icons.copy),
|
||||
),
|
||||
if (campaignDto.id != null)
|
||||
GestureDetector(
|
||||
onLongPress: _deleteDiscountCampaign,
|
||||
child: Icon(Icons.delete),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownMenu<int?>(
|
||||
initialSelection: null,
|
||||
onSelected: (int? value) {
|
||||
setCampaign(() {
|
||||
_init(
|
||||
_campaigns
|
||||
.firstWhereOrNull((v) => v.id == value),
|
||||
);
|
||||
});
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
..._campaigns,
|
||||
null,
|
||||
].map((campaign) {
|
||||
return DropdownMenuEntry(
|
||||
value: campaign?.id,
|
||||
label: campaign == null
|
||||
? 'New'
|
||||
: '${campaign.name ?? campaign.id} ${campaign.status.name}',
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextParam(
|
||||
'Name',
|
||||
campaignDto.name,
|
||||
(v) {
|
||||
campaignDto = campaignDto.copyWith(
|
||||
name: v,
|
||||
);
|
||||
},
|
||||
),
|
||||
DateParam(
|
||||
'Start',
|
||||
campaignDto.start,
|
||||
(v) {
|
||||
setState(() {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(start: v);
|
||||
});
|
||||
},
|
||||
),
|
||||
DateParam(
|
||||
'Finish',
|
||||
campaignDto.finish,
|
||||
(v) {
|
||||
setState(() {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(finish: v);
|
||||
});
|
||||
},
|
||||
),
|
||||
EnumParam(
|
||||
'Status',
|
||||
DiscountCampaignStatus.values,
|
||||
campaignDto.status, (v) {
|
||||
setState(() {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(status: v);
|
||||
});
|
||||
}),
|
||||
Text(
|
||||
'Created to create or change\n'
|
||||
'Disabled to disable\n'
|
||||
'Dont use other statuses!',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
Text('Discounts:'),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount:
|
||||
campaignDto.discounts.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index ==
|
||||
campaignDto.discounts.length) {
|
||||
return Container(
|
||||
child: SimpleTile(
|
||||
title: Text('Add'),
|
||||
onTap: () async {
|
||||
final discount =
|
||||
await _showDiscountDialog(
|
||||
context, null);
|
||||
if (discount != null) {
|
||||
setState(() {
|
||||
final ex = campaignDto
|
||||
.discounts
|
||||
.firstWhereOrNull((d) =>
|
||||
d.id ==
|
||||
discount.id);
|
||||
if (ex != null) {
|
||||
campaignDto.discounts
|
||||
.remove(ex);
|
||||
}
|
||||
campaignDto.discounts
|
||||
.add(discount);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final item =
|
||||
campaignDto.discounts[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
'${item.type.name} ${item.id ?? ''}'),
|
||||
onTap: () async {
|
||||
final p = await _showDiscountDialog(
|
||||
context,
|
||||
item,
|
||||
);
|
||||
if (p != null) {
|
||||
setState(() {
|
||||
campaignDto.discounts.add(p);
|
||||
});
|
||||
}
|
||||
},
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
campaignDto.discounts
|
||||
.remove(item);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<DiscountDto?> _showDiscountDialog(
|
||||
BuildContext context,
|
||||
DiscountDto? initialDiscount,
|
||||
) async {
|
||||
var prod = initialDiscount ?? DiscountDto(discountPercent: 0, products: []);
|
||||
final result = await showDialog<DiscountDto?>(
|
||||
context: context,
|
||||
builder: (c) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Material(
|
||||
child: FutureBuilder(
|
||||
future: () async {
|
||||
final allPacks = await adminApi.packs();
|
||||
final allPlans = await adminApi.getSubscriptionPlans();
|
||||
return [...allPacks, ...allPlans];
|
||||
}.call(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text('Loading products...'),
|
||||
);
|
||||
}
|
||||
final products = snapshot.requireData;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Adding discount'),
|
||||
TextParam(
|
||||
'Id',
|
||||
prod.id,
|
||||
(v) {
|
||||
prod = prod.copyWith(id: v);
|
||||
},
|
||||
description: 'Leave empty for new',
|
||||
),
|
||||
IntParam('Discount 0-100', prod.discountPercent.toInt(),
|
||||
(v) {
|
||||
prod = prod.copyWith(
|
||||
discountPercent: v?.toDouble() ?? 0.0);
|
||||
}),
|
||||
StatefulBuilder(builder: (context, ss) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: prod.products.length + 1,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index == prod.products.length) {
|
||||
return EnumParam(
|
||||
'Add product',
|
||||
products,
|
||||
null,
|
||||
(p) {
|
||||
if (p != null) {
|
||||
ss(() {
|
||||
prod.products.add(p);
|
||||
});
|
||||
}
|
||||
},
|
||||
getName: (p) => _productName(p, products),
|
||||
);
|
||||
}
|
||||
final p = prod.products[index];
|
||||
return ListTile(
|
||||
title: Text(_productName(p, products)),
|
||||
onTap: () async {
|
||||
ss(() {
|
||||
prod.products.remove(p);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
MaterialButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(prod);
|
||||
},
|
||||
child: Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
String _productName(MnemoCardsProductDto? p, List<MnemoCardsProductDto> products) {
|
||||
if (p is CardPackPreviewDto) {
|
||||
return '${p.id} ${p.title} ${p.price} pack';
|
||||
} else if (p is SubscriptionPlanAdminDto) {
|
||||
return '${p.id} ${p.ui.titleLead ?? ''} ${p.ui.title ?? ''} ${p.paymentSystem.name} ${p.price} plan';
|
||||
}
|
||||
final matchedProduct = products.firstWhereOrNull((m) => m.productEquals(p));
|
||||
if (matchedProduct != null) {
|
||||
return _productName(matchedProduct, []);
|
||||
}
|
||||
return '${p?.type.name} ${p?.id}';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'package:universal_io/io.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards/managers/repository/api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
import '../utils/string_helper.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import 'text_param.dart';
|
||||
|
||||
class AddPackage with Api {
|
||||
EditCardPackDto cardPackDto;
|
||||
AdminApi adminApi;
|
||||
bool palleteVisible = false;
|
||||
|
||||
AddPackage(EditCardPackDto? cardPackDto, this.adminApi)
|
||||
: cardPackDto = cardPackDto ?? EditCardPackDto.fromJson({});
|
||||
|
||||
void _deletePack() async {
|
||||
final delete = await showDialog(
|
||||
context: scaffoldKey.currentContext!,
|
||||
builder: (c) => Center(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
width: 200,
|
||||
child: IconButton(
|
||||
icon: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Удалить '),
|
||||
Icon(Icons.delete),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(c).pop(true);
|
||||
},
|
||||
),
|
||||
),
|
||||
));
|
||||
if (!delete) return;
|
||||
final result = await adminApi.deletePack(cardPackDto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _savePack() async {
|
||||
final result = await adminApi.editPack(cardPackDto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void addPack(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) {
|
||||
return Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListView(
|
||||
children: [
|
||||
Header(
|
||||
'${cardPackDto.id}',
|
||||
hasPopButton: true,
|
||||
trail: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _savePack,
|
||||
icon: Icon(Icons.save),
|
||||
),
|
||||
if (cardPackDto.id != null)
|
||||
GestureDetector(
|
||||
onLongPress: _deletePack,
|
||||
child: Icon(Icons.delete),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
TextParam(
|
||||
'Title',
|
||||
cardPackDto.title,
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.title(v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Subtitle',
|
||||
cardPackDto.subtitle,
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.subtitle(v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Description',
|
||||
cardPackDto.description,
|
||||
(v) => cardPackDto = cardPackDto.copyWith.description(v),
|
||||
),
|
||||
TextParam(
|
||||
'Size',
|
||||
cardPackDto.size?.toString(),
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.size(
|
||||
int.tryParse(v),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Google play id',
|
||||
cardPackDto.googlePlayId,
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.googlePlayId(v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Rustore id',
|
||||
cardPackDto.rustoreId,
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.rustoreId(v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Appstore id',
|
||||
cardPackDto.appStoreId,
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.appStoreId(v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Price',
|
||||
cardPackDto.price,
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.price(v);
|
||||
},
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('Enabled ')),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Switch(
|
||||
value: cardPackDto.enabled ?? false,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
cardPackDto = cardPackDto.copyWith.enabled(v);
|
||||
});
|
||||
});
|
||||
}),
|
||||
],
|
||||
),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('Поменять цвет ')),
|
||||
Switch(
|
||||
value: palleteVisible,
|
||||
onChanged: (v) => setState(() {
|
||||
palleteVisible = !palleteVisible;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (palleteVisible)
|
||||
ColorPicker(
|
||||
pickerAreaHeightPercent: 0.5,
|
||||
paletteType: PaletteType.hsvWithHue,
|
||||
pickerColor: cardPackDto.color?.asColor ??
|
||||
Colors.transparent,
|
||||
onColorChanged: (color) {
|
||||
String? colorString;
|
||||
if (color.alpha != 0) {
|
||||
colorString = '#${color.toHexString()}';
|
||||
}
|
||||
cardPackDto =
|
||||
cardPackDto.copyWith.color(colorString);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return SizedBox(
|
||||
width: MediaQuery.of(context).size.width - 20,
|
||||
height: MediaQuery.of(context).size.width - 20,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
FilePickerResult? result =
|
||||
await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final file = result.files.first;
|
||||
final encoded = base64.normalize(
|
||||
base64.encode(
|
||||
File(file.path!).readAsBytesSync(),
|
||||
),
|
||||
);
|
||||
cardPackDto = cardPackDto.copyWith.cover(
|
||||
encoded,
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
child: cardPackDto.cover == null
|
||||
? Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.white,
|
||||
child: Icon(
|
||||
Icons.image,
|
||||
color: Colors.grey,
|
||||
),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
ImageFade(
|
||||
key: ValueKey(cardPackDto.id),
|
||||
image: MemoryImage(
|
||||
base64.decode(cardPackDto.cover!)),
|
||||
fit: BoxFit.contain,
|
||||
syncDuration: Duration.zero,
|
||||
),
|
||||
Positioned(
|
||||
top: 8.0,
|
||||
right: 8.0,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
cardPackDto = cardPackDto.copyWith(
|
||||
cover: null,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
icon: Icon(Icons.delete)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
TextParam(
|
||||
'Preview cards',
|
||||
cardPackDto.previewCards?.toString(),
|
||||
(v) {
|
||||
final cards = v
|
||||
.decodeList<int>()
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
cardPackDto = cardPackDto.copyWith.previewCards(cards);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Order (lower is higher)',
|
||||
cardPackDto.order?.toString(),
|
||||
(v) {
|
||||
cardPackDto = cardPackDto.copyWith.order(
|
||||
int.tryParse(v),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Cards order (ids)',
|
||||
cardPackDto.cardsOrder?.join(','),
|
||||
(v) {
|
||||
final ids = v
|
||||
.split(',')
|
||||
.map((s) => int.tryParse(s.trim()))
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
cardPackDto = cardPackDto.copyWith(cardsOrder: ids);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,389 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../main.dart';
|
||||
import 'text_param.dart';
|
||||
|
||||
class AddPlan {
|
||||
List<SubscriptionPlanAdminDto> allPlans = [];
|
||||
List<SubscriptionFeatureEnum> selectedFeatures = [];
|
||||
late SubscriptionPlanAdminDto planDto;
|
||||
late AdminApi adminApi;
|
||||
|
||||
final availableFeatures = [
|
||||
SubscriptionFeatureEnum.ads,
|
||||
SubscriptionFeatureEnum.packs,
|
||||
];
|
||||
|
||||
AddPlan() {
|
||||
this.adminApi = getIt.get<AdminApi>();
|
||||
_init(null);
|
||||
}
|
||||
|
||||
void _init(SubscriptionPlanAdminDto? dto) {
|
||||
planDto = dto ??
|
||||
const SubscriptionPlanAdminDto(
|
||||
ui: SubscriptionPlanUIDto(
|
||||
currency: 'руб',
|
||||
),
|
||||
price: '123',
|
||||
currency: 'руб',
|
||||
durationDays: 0,
|
||||
features: [
|
||||
SubscriptionFeatureEnum.ads,
|
||||
SubscriptionFeatureEnum.packs,
|
||||
],
|
||||
paymentId: null,
|
||||
paymentSystem: PaymentSystem.unknown,
|
||||
);
|
||||
selectedFeatures = planDto.features.toList();
|
||||
}
|
||||
|
||||
Future<bool> _loadPlans() async {
|
||||
final result = await adminApi.getSubscriptionPlans();
|
||||
allPlans = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _saveSubscriptionPlan() async {
|
||||
final result = await adminApi.addSubscriptionPlan(
|
||||
planDto.copyWith(
|
||||
features: selectedFeatures,
|
||||
),
|
||||
);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _deletePLan() async {
|
||||
if (planDto.id == null) return;
|
||||
final result = await adminApi.deleteSubscriptionPlan(planDto.id.toString());
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void addPlan(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) {
|
||||
return FutureBuilder(
|
||||
future: _loadPlans(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data != true) {
|
||||
return Center(
|
||||
child: Material(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Scaffold(
|
||||
body: Material(
|
||||
child: StatefulBuilder(builder: (context, setPlan) {
|
||||
return ListView(
|
||||
children: [
|
||||
Header(
|
||||
'${planDto.id}',
|
||||
hasPopButton: true,
|
||||
trail: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await _loadPlans();
|
||||
setPlan(() {});
|
||||
},
|
||||
icon: Icon(Icons.sync),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _saveSubscriptionPlan,
|
||||
icon: Icon(Icons.save),
|
||||
),
|
||||
if (planDto.id != null)
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
planDto = planDto.copyWith(id: null);
|
||||
_saveSubscriptionPlan();
|
||||
},
|
||||
icon: Icon(Icons.copy),
|
||||
),
|
||||
if (planDto.id != null)
|
||||
GestureDetector(
|
||||
onLongPress: _deletePLan,
|
||||
child: Icon(Icons.delete),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownMenu<String?>(
|
||||
initialSelection: null,
|
||||
onSelected: (String? value) {
|
||||
setPlan(() {
|
||||
_init(
|
||||
allPlans
|
||||
.firstWhereOrNull((v) => v.id == value),
|
||||
);
|
||||
});
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
...allPlans,
|
||||
null,
|
||||
].map((plan) {
|
||||
return DropdownMenuEntry(
|
||||
value: plan?.id,
|
||||
label: plan == null
|
||||
? 'New'
|
||||
: '${plan.ui.titleLead ?? ''} ${plan.ui.title ?? ''} ${plan.paymentSystem.name}',
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
TextParam(
|
||||
'Currency',
|
||||
planDto.currency,
|
||||
(v) {
|
||||
planDto = planDto.copyWith(
|
||||
currency: v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Price',
|
||||
planDto.price,
|
||||
(v) {
|
||||
planDto =
|
||||
planDto.copyWith(price: v);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Days',
|
||||
planDto.durationDays.toString(),
|
||||
(v) {
|
||||
final days = int.parse(v);
|
||||
planDto = planDto.copyWith(
|
||||
durationDays: days,
|
||||
);
|
||||
},
|
||||
),
|
||||
Text('Features:'),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount:
|
||||
availableFeatures.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item =
|
||||
availableFeatures[index];
|
||||
return ListTile(
|
||||
title: Text(item.name),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selectedFeatures
|
||||
.contains(item)) {
|
||||
selectedFeatures
|
||||
.remove(item);
|
||||
} else {
|
||||
selectedFeatures
|
||||
.add(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
selected: selectedFeatures
|
||||
.contains(item),
|
||||
selectedTileColor: Colors.blue
|
||||
.withOpacity(0.5),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text('UI:'),
|
||||
TextParam(
|
||||
'UI Currency',
|
||||
planDto.ui.currency,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
currency: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Title lead',
|
||||
planDto.ui.titleLead,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
titleLead: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Title',
|
||||
planDto.ui.title,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
title: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Price per month',
|
||||
planDto.ui.pricePerMonth,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
pricePerMonth: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Subtitle crossed',
|
||||
planDto.ui.subtitleCrossed,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
subtitleCrossed: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Subtitle',
|
||||
planDto.ui.subtitle,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
subtitle: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Tip',
|
||||
planDto.ui.tip,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
tip: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'UI Tip color',
|
||||
planDto.ui.tipColor,
|
||||
(v) {
|
||||
final ui = planDto.ui.copyWith(
|
||||
tipColor: v,
|
||||
);
|
||||
planDto =
|
||||
planDto.copyWith.ui(ui);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Payment type '),
|
||||
Expanded(
|
||||
child: StatefulBuilder(
|
||||
builder: (context,
|
||||
setState) {
|
||||
return DropdownButton<
|
||||
PaymentSystem>(
|
||||
value: planDto
|
||||
.paymentSystem,
|
||||
items:
|
||||
PaymentSystem.values
|
||||
.map(
|
||||
(v) =>
|
||||
DropdownMenuItem(
|
||||
child: Text(
|
||||
v.name),
|
||||
value: v,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
planDto = planDto
|
||||
.copyWith(
|
||||
paymentSystem: v ??
|
||||
PaymentSystem
|
||||
.unknown,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextParam(
|
||||
'Payment id',
|
||||
planDto.paymentId,
|
||||
(v) {
|
||||
planDto = planDto.copyWith(
|
||||
paymentId: v,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget uiEditor() {
|
||||
return Column(
|
||||
children: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../main.dart';
|
||||
import 'date_param.dart';
|
||||
import 'enum_param.dart';
|
||||
import 'int_param.dart';
|
||||
import 'text_param.dart';
|
||||
|
||||
class AddPromocodeCampaign {
|
||||
late PromoCodesCampaignDto campaignDto;
|
||||
late AdminApi adminApi;
|
||||
List<PromoCodesCampaignDto> _campaigns = [];
|
||||
|
||||
final availableStatuses = [
|
||||
PromoCodeCampaignStatus.created,
|
||||
PromoCodeCampaignStatus.preparing,
|
||||
PromoCodeCampaignStatus.ready,
|
||||
PromoCodeCampaignStatus.active,
|
||||
PromoCodeCampaignStatus.disabled,
|
||||
];
|
||||
|
||||
AddPromocodeCampaign() {
|
||||
this.adminApi = getIt.get<AdminApi>();
|
||||
_init(null);
|
||||
}
|
||||
|
||||
void _init(PromoCodesCampaignDto? dto) {
|
||||
final now = DateTime.now();
|
||||
campaignDto = dto ??
|
||||
PromoCodesCampaignDto(
|
||||
products: [],
|
||||
activationsPerCode: 1,
|
||||
activationsPerUser: 1,
|
||||
generationSize: 100,
|
||||
start: now,
|
||||
finish: now.add(Duration(days: 60)),
|
||||
status: PromoCodeCampaignStatus.disabled,
|
||||
name: null,
|
||||
template: r'$s$s$s$s$s$s$s$s',
|
||||
tags: [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _loadCampaigns() async {
|
||||
try {
|
||||
_campaigns = await adminApi.getPromoCodeCampaigns();
|
||||
} catch (e, s) {
|
||||
log('Error', error: e, stackTrace: s);
|
||||
print('Error $e');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _loadCampaign(String id) async {
|
||||
try {
|
||||
final campaign = await adminApi.getPromoCodeCampaign(id);
|
||||
if (campaign != null) {
|
||||
_campaigns.removeWhere((v) => v.id.toString() == id);
|
||||
_campaigns.add(campaign);
|
||||
campaignDto = campaign;
|
||||
}
|
||||
} catch (e, s) {
|
||||
log('Error', error: e, stackTrace: s);
|
||||
print('Error $e');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _savePromoCodeCampaign() async {
|
||||
final result = await adminApi.addPromoCodeCampaign(campaignDto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _deletePromoCodeCampaign() async {
|
||||
if (campaignDto.id == null) return;
|
||||
final result =
|
||||
await adminApi.deletePromoCodeCampaign(campaignDto.id.toString());
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void addCampaign(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) {
|
||||
return FutureBuilder(
|
||||
future: _loadCampaigns(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data != true) {
|
||||
return Center(
|
||||
child: Material(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Scaffold(
|
||||
body: Material(
|
||||
child: StatefulBuilder(builder: (context, setCampaign) {
|
||||
final codes = campaignDto.promoCodes;
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
Header(
|
||||
'${campaignDto.id}',
|
||||
hasPopButton: true,
|
||||
trail: Row(
|
||||
children: [
|
||||
if (campaignDto.id != null)
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await _loadCampaign(
|
||||
campaignDto.id.toString(),
|
||||
);
|
||||
setCampaign(() {});
|
||||
},
|
||||
icon: Icon(Icons.note),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _savePromoCodeCampaign,
|
||||
icon: Icon(Icons.save),
|
||||
),
|
||||
if (campaignDto.id != null)
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(id: null);
|
||||
_savePromoCodeCampaign();
|
||||
},
|
||||
icon: Icon(Icons.copy),
|
||||
),
|
||||
if (campaignDto.id != null)
|
||||
GestureDetector(
|
||||
onLongPress: _deletePromoCodeCampaign,
|
||||
child: Icon(Icons.delete),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownMenu<int?>(
|
||||
initialSelection: null,
|
||||
onSelected: (int? value) {
|
||||
setCampaign(() {
|
||||
_init(
|
||||
_campaigns
|
||||
.firstWhereOrNull((v) => v.id == value),
|
||||
);
|
||||
});
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
..._campaigns,
|
||||
null,
|
||||
].map((campaign) {
|
||||
return DropdownMenuEntry(
|
||||
value: campaign?.id,
|
||||
label: campaign == null
|
||||
? 'New'
|
||||
: '${campaign.name ?? campaign.id} ${campaign.status.name}',
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextParam(
|
||||
'Name',
|
||||
campaignDto.name,
|
||||
(v) {
|
||||
campaignDto = campaignDto.copyWith(
|
||||
name: v,
|
||||
);
|
||||
},
|
||||
),
|
||||
TextParam(
|
||||
'Template',
|
||||
campaignDto.template,
|
||||
(v) {
|
||||
campaignDto = campaignDto.copyWith(
|
||||
template: v,
|
||||
);
|
||||
},
|
||||
description:
|
||||
r'$d - digit $l - letter $s - any',
|
||||
),
|
||||
IntParam(
|
||||
'Generation size',
|
||||
campaignDto.generationSize,
|
||||
(v) {
|
||||
campaignDto = campaignDto.copyWith(
|
||||
generationSize: v);
|
||||
},
|
||||
),
|
||||
IntParam(
|
||||
'Activations per code',
|
||||
campaignDto.activationsPerCode,
|
||||
(v) {
|
||||
campaignDto = campaignDto.copyWith(
|
||||
activationsPerCode: v,
|
||||
);
|
||||
},
|
||||
),
|
||||
IntParam(
|
||||
'Activations per user',
|
||||
campaignDto.activationsPerUser,
|
||||
(v) {
|
||||
campaignDto = campaignDto.copyWith(
|
||||
activationsPerUser: v,
|
||||
);
|
||||
},
|
||||
),
|
||||
DateParam(
|
||||
'Start',
|
||||
campaignDto.start,
|
||||
(v) {
|
||||
setState(() {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(start: v);
|
||||
});
|
||||
},
|
||||
),
|
||||
DateParam(
|
||||
'Finish',
|
||||
campaignDto.finish,
|
||||
(v) {
|
||||
setState(() {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(finish: v);
|
||||
});
|
||||
},
|
||||
),
|
||||
EnumParam(
|
||||
'Status',
|
||||
PromoCodeCampaignStatus.values,
|
||||
campaignDto.status, (v) {
|
||||
setState(() {
|
||||
campaignDto =
|
||||
campaignDto.copyWith(status: v);
|
||||
});
|
||||
}),
|
||||
Text(
|
||||
'Created to generate promocodes\n'
|
||||
'Disabled to disable\n'
|
||||
'Dont use other statuses!',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
Text('Products:'),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount:
|
||||
campaignDto.products.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index ==
|
||||
campaignDto.products.length) {
|
||||
return ListTile(
|
||||
title: Text('Add'),
|
||||
onTap: () async {
|
||||
final p =
|
||||
await _showProductDialog(
|
||||
context, null);
|
||||
if (p != null) {
|
||||
setState(() {
|
||||
campaignDto.products.add(p);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
final item =
|
||||
campaignDto.products[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
'${item.type.name} ${item.id ?? ''}'),
|
||||
onTap: () async {
|
||||
final p = await _showProductDialog(
|
||||
context,
|
||||
item,
|
||||
);
|
||||
if (p != null) {
|
||||
setState(() {
|
||||
campaignDto.products.add(p);
|
||||
});
|
||||
}
|
||||
},
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
campaignDto.products
|
||||
.remove(item);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SimpleTile.text(
|
||||
text: codes == null
|
||||
? 'Codes not loaded'
|
||||
: 'Codes: ${codes.length == 1 ? codes.first : codes.length}',
|
||||
onTap: () async {
|
||||
if (codes != null) {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: codes.join('\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<MnemoCardsProductDto?> _showProductDialog(
|
||||
BuildContext context,
|
||||
MnemoCardsProductDto? initialProduct,
|
||||
) async {
|
||||
var prod = initialProduct ?? MnemoCardsProductDto();
|
||||
final result = await showDialog<MnemoCardsProductDto?>(
|
||||
context: context,
|
||||
builder: (c) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextParam('Id', prod.id, (v) {
|
||||
prod = prod.copyWith(id: v);
|
||||
}),
|
||||
EnumParam(
|
||||
'Type',
|
||||
MnemoCardsProductType.values,
|
||||
prod.type,
|
||||
(v) {
|
||||
prod = prod.copyWith(type: v);
|
||||
},
|
||||
),
|
||||
MaterialButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(prod);
|
||||
},
|
||||
child: Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget uiEditor() {
|
||||
return Column(
|
||||
children: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class EditUser {
|
||||
UserDto userDto;
|
||||
AdminApi adminApi;
|
||||
bool imageChanged = false;
|
||||
|
||||
EditUser(UserDto userDto, this.adminApi) : userDto = userDto;
|
||||
|
||||
void _saveUser() async {
|
||||
final dto = userDto;
|
||||
final result = await adminApi.editUser(dto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteCard() async {
|
||||
final result = await adminApi.deleteUser(userDto);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
appRouter.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
void addUser(BuildContext context) {
|
||||
final theme = Theme.of(context).textTheme;
|
||||
|
||||
final TextEditingController nameController =
|
||||
TextEditingController(text: userDto.name);
|
||||
|
||||
final TextEditingController packsController =
|
||||
TextEditingController(text: userDto.packs.join(','));
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) {
|
||||
return Center(
|
||||
child: Scaffold(
|
||||
body: Material(
|
||||
child: ListView(
|
||||
children: [
|
||||
Header(
|
||||
'${userDto.id}',
|
||||
hasPopButton: true,
|
||||
trail: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _saveUser,
|
||||
icon: Icon(Icons.save),
|
||||
),
|
||||
if (userDto.id != null && userDto.id! >= 0)
|
||||
GestureDetector(
|
||||
onLongPress: _deleteCard,
|
||||
child: Icon(Icons.delete),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (userDto.id != null) {
|
||||
Clipboard.setData(ClipboardData(
|
||||
text: userDto.id.toString()));
|
||||
}
|
||||
},
|
||||
child: Text('ID: ${userDto.id}'),
|
||||
),
|
||||
Text('Admin: ${userDto.admin}'),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
onChanged: (v) {
|
||||
userDto = userDto.copyWith(name: v);
|
||||
},
|
||||
decoration: InputDecoration(hintText: 'Name'),
|
||||
maxLines: 1,
|
||||
style: theme.displayMedium?.copyWith(
|
||||
fontSize: theme.displayMedium!.fontSize!,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('Subscription ')),
|
||||
StatefulBuilder(builder: (context, setState) {
|
||||
return Switch(
|
||||
value: userDto.subscription ?? false,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
userDto =
|
||||
userDto.copyWith.subscription(v);
|
||||
});
|
||||
});
|
||||
}),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: packsController,
|
||||
onChanged: (v) {
|
||||
final packs = v
|
||||
.split(',')
|
||||
.map((s) =>
|
||||
int.tryParse(s.trim())?.toString())
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
userDto = userDto.copyWith(packs: packs);
|
||||
final packsText = packs.join(',');
|
||||
if (!packsController.text
|
||||
.startsWith(packsText)) {
|
||||
packsController.text = packsText;
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(hintText: 'Packs'),
|
||||
maxLines: 1,
|
||||
style: theme.displayMedium?.copyWith(
|
||||
fontSize: theme.displayMedium!.fontSize!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
_Payments(adminApi, userDto),
|
||||
Text(
|
||||
'Stats: ${userDto.userDataDto?.allWordsStatistics?.total} words',
|
||||
),
|
||||
Text(
|
||||
'Stats: ${userDto.userDataDto?.allTestsStatistics?.tests.length} tests',
|
||||
),
|
||||
Text(
|
||||
'Last test session: ${userDto.userDataDto?.allTestsStatistics?.lastSessionToken}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _Payments extends StatelessWidget {
|
||||
final AdminApi adminApi;
|
||||
final UserDto userDto;
|
||||
|
||||
_Payments(this.adminApi, this.userDto);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sc = StreamController<List<PaymentDto>?>();
|
||||
final stream = sc.stream;
|
||||
|
||||
return ExpansionTile(
|
||||
title: Text('Транзакции'),
|
||||
maintainState: true,
|
||||
onExpansionChanged: (v) async {
|
||||
if (v) {
|
||||
sc.add(null);
|
||||
final payments =
|
||||
await adminApi.getUserPurchases(userDto.id.toString());
|
||||
sc.add(payments);
|
||||
}
|
||||
},
|
||||
children: [
|
||||
StreamBuilder<List<PaymentDto>?>(
|
||||
stream: stream,
|
||||
builder: (context, snapshot) {
|
||||
final payments = snapshot.data;
|
||||
if (payments == null) {
|
||||
return Text('Loading...');
|
||||
}
|
||||
if (payments.isEmpty) {
|
||||
return Text('Нет покупок');
|
||||
}
|
||||
return Column(
|
||||
children: payments.map((p) {
|
||||
return ListTile(
|
||||
title: Text('${p.amount} ${p.currency}'),
|
||||
subtitle: Text(dateString(p.date)),
|
||||
trailing: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(p.status.name),
|
||||
Text(p.paymentSystem.name),
|
||||
Text(
|
||||
p.products.map((p) => '${p.type}: ${p.id}').join('\n'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String dateString(DateTime date) =>
|
||||
'${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:mnemo_cards/di/injector.dart';
|
||||
import 'package:mnemo_cards/features/packs/packs_api.dart';
|
||||
import 'package:mnemo_cards/features/worker/worker_ext.dart';
|
||||
import 'package:mnemo_cards/managers/repository/api.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
class AdminApi with Api {
|
||||
final Dio _dio;
|
||||
|
||||
AdminApi(this._dio);
|
||||
|
||||
Future<bool> addCard(
|
||||
GameCardDto dto, {
|
||||
ProgressCallback? progressCallback,
|
||||
}) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/cards/add',
|
||||
data: jsonEncode(dto.toJson()),
|
||||
options: Options(sendTimeout: Duration(seconds: 30)),
|
||||
onSendProgress: (a, b) {
|
||||
log('Card send progress $a/$b');
|
||||
progressCallback?.call(a, b);
|
||||
},
|
||||
onReceiveProgress: (a, b) {
|
||||
log('Card receive progress $a/$b');
|
||||
progressCallback?.call(a, b);
|
||||
},
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> deleteCard(GameCardDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/cards/delete',
|
||||
data: jsonEncode({'id': dto.id.toString()}),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<List<String>> getCardIds() async {
|
||||
final r = await _dio.get<String>('$path/cards/ids');
|
||||
return (jsonDecode(r.data!)['ids'] as String).split(',').cast();
|
||||
}
|
||||
|
||||
Future<List<GameCardDto>> getCards(List<String> ids) async {
|
||||
final r = await _dio.get<String>(
|
||||
'$path/cards',
|
||||
queryParameters: {'ids': ids.join(',')},
|
||||
);
|
||||
return (r.data as String).decodeList(GameCardDto.fromJson);
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getUsers(List<String> ids) async {
|
||||
final r = await _dio.get<String>(
|
||||
'$path/users',
|
||||
queryParameters: {'ids': ids.join(',')},
|
||||
);
|
||||
return (jsonDecode(r.data!) as List)
|
||||
.map((e) => UserDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
.cast();
|
||||
}
|
||||
|
||||
Future<List<String>> getUserIds() async {
|
||||
final r = await _dio.get<String>('$path/users/ids');
|
||||
return (jsonDecode(r.data!)['ids'] as String).split(',').cast();
|
||||
}
|
||||
|
||||
Future<List<PaymentDto>> getUserPurchases(String userId) async {
|
||||
final r = await _dio.get<String>(
|
||||
'$path/user/purchases',
|
||||
queryParameters: {'id': userId},
|
||||
);
|
||||
return (jsonDecode(r.data!)['payments'] as List)
|
||||
.cast<Map<String, Object?>>()
|
||||
.map(PaymentDto.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<bool> deleteUser(UserDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/users/delete',
|
||||
data: jsonEncode({'id': dto.id.toString()}),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> editUser(UserDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/users/add',
|
||||
data: jsonEncode(dto.toJson()),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<EditCardPackDto?> getEditPack(String id) async {
|
||||
final r = await _dio.get<String>('$path/pack/edit/$id');
|
||||
return (r.data as String).decode(EditCardPackDto.fromJson);
|
||||
}
|
||||
|
||||
Future<bool> deletePack(EditCardPackDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/pack/delete',
|
||||
data: jsonEncode({'id': dto.id.toString()}),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> editPack(EditCardPackDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/pack/edit',
|
||||
data: jsonEncode(dto.toJson()),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<List<CardPackPreviewDto>> packs() =>
|
||||
getIt.get<PacksApi>().getPacksPreviews(null);
|
||||
|
||||
Future<List<SubscriptionPlanAdminDto>> getSubscriptionPlans() async {
|
||||
final r = await _dio.get<String>('$path/subscriptions');
|
||||
return (jsonDecode(r.data!)['plans'] as List)
|
||||
.cast<Map<String, Object?>>()
|
||||
.map(SubscriptionPlanAdminDto.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<bool> addSubscriptionPlan(SubscriptionPlanAdminDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/subscription/add',
|
||||
data: jsonEncode(dto.toJson()),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> deleteSubscriptionPlan(String id) async {
|
||||
final r = await _dio.post<String>('$path/subscription/delete/$id');
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> deletePromoCodeCampaign(String id) async {
|
||||
final r = await _dio.post<String>('$path/promocode/delete/$id');
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> addPromoCodeCampaign(PromoCodesCampaignDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/promocode/add',
|
||||
data: dto.encode(),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<List<PromoCodesCampaignDto>> getPromoCodeCampaigns() async {
|
||||
final r = await _dio.get<String>('$path/promocode/list');
|
||||
return (jsonDecode(r.data!)['campaigns'] as List)
|
||||
.cast<Map<String, Object?>>()
|
||||
.map(PromoCodesCampaignDto.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<PromoCodesCampaignDto?> getPromoCodeCampaign(String id) async {
|
||||
final r = await _dio.get<String>('$path/promocode/$id');
|
||||
return r.data?.isolatedDecode(PromoCodesCampaignDto.fromJson);
|
||||
}
|
||||
|
||||
Future<bool> addDiscountCampaign(DiscountCampaignDto dto) async {
|
||||
final r = await _dio.post<String>(
|
||||
'$path/discounts/add',
|
||||
data: dto.encode(),
|
||||
);
|
||||
return r.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<List<DiscountCampaignDto>> getDiscountCampaigns() async {
|
||||
final r = await _dio.get<String>('$path/discounts/list');
|
||||
return (jsonDecode(r.data!)['campaigns'] as List)
|
||||
.cast<Map<String, Object?>>()
|
||||
.map(DiscountCampaignDto.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<String?> deleteDiscountCampaign(String id) async {
|
||||
try {
|
||||
final r = await _dio.post<String>('$path/discounts/delete/$id');
|
||||
return (jsonDecode(r.data!)['error'] as String?);
|
||||
} catch (e, s) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:mnemo_cards/admin/add_card.dart';
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards/di/injector.dart';
|
||||
import 'package:mnemo_cards/managers/repository/dio_provider.dart';
|
||||
import 'package:mnemo_cards/theme/themes.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import 'add_package.dart';
|
||||
|
||||
class AllCards extends StatefulWidget {
|
||||
@override
|
||||
State<StatefulWidget> createState() => _AllCardsState();
|
||||
|
||||
const AllCards();
|
||||
}
|
||||
|
||||
class _AllCardsState extends State<AllCards> {
|
||||
static List<String> ids = [];
|
||||
static Set<GameCardDto> cards = {};
|
||||
static String filter = '';
|
||||
static List<CardPackPreviewDto> _packPreviews = [];
|
||||
static EditCardPackDto? _currentPack;
|
||||
static String? _currentId;
|
||||
static List<String> _selectedCards = [];
|
||||
static Map<String, String> _cache = {};
|
||||
|
||||
bool get selectingMode => _selectedCards.isNotEmpty;
|
||||
|
||||
Dio get dio => getIt.get<DioProvider>().dio;
|
||||
|
||||
AdminApi get api => getIt.get<AdminApi>();
|
||||
|
||||
Future<void> loadCards() async {
|
||||
var loadingIds = ids.toList();
|
||||
cards.clear();
|
||||
_cache.clear();
|
||||
while (loadingIds.isNotEmpty) {
|
||||
try {
|
||||
final ids = loadingIds.take(10).toList();
|
||||
loadingIds.removeRange(0, ids.length);
|
||||
final cardsPage = await api.getCards(ids);
|
||||
for (final card in cardsPage) {
|
||||
final imageString =
|
||||
card.image?.substring(0, min(100, card.image!.length)) ?? '';
|
||||
_cache[card.id.toString()] =
|
||||
'${card.id}${card.mnemo}${card.transcription}${card.transcriptionMnemo}${card.original}${card.translation}${imageString}';
|
||||
}
|
||||
cards.addAll(cardsPage);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadIds() async {
|
||||
ids = await api.getCardIds();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> loadPacks() async {
|
||||
_packPreviews = await api.packs();
|
||||
clearCurrentPack();
|
||||
}
|
||||
|
||||
Future<void> loadCurrentPack() async {
|
||||
if (_currentId != null) {
|
||||
_currentPack = await api.getEditPack(_currentId!);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void clearCurrentPack() {
|
||||
_currentId = null;
|
||||
_currentPack = null;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> editPack(EditCardPackDto pack) async {
|
||||
final result = await api.editPack(pack);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
loadCurrentPack();
|
||||
_selectedCards.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void addOrEditPack() => AddPackage(_currentPack, api).addPack(context);
|
||||
|
||||
List<GameCardDto> buildCards() {
|
||||
final filteredCards = cards.where((dto) {
|
||||
final filterOk =
|
||||
filter.isEmpty || _cache[dto.id.toString()]?.contains(filter) == true;
|
||||
final packFilter =
|
||||
_currentPack?.addCardIds?.contains(dto.id.toString()) ?? true;
|
||||
return filterOk && packFilter;
|
||||
});
|
||||
return filteredCards.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filteredCards = buildCards();
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
itemExtentBuilder: (index, dims) => index >= 1 ? 160.h : 350,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0)
|
||||
return Column(
|
||||
children: [
|
||||
Header(
|
||||
_currentId == null
|
||||
? 'Все карточки'
|
||||
: _currentPack?.title ??
|
||||
'Пак ${_currentPack?.id}',
|
||||
subtitle: filteredCards.isEmpty
|
||||
? 'Нет карточек'
|
||||
: '${filteredCards.length} карточек',
|
||||
hasPopButton: true,
|
||||
trail: IconButton(
|
||||
onPressed: () async {
|
||||
setState(() {});
|
||||
await loadIds();
|
||||
await loadPacks();
|
||||
await loadCards();
|
||||
},
|
||||
icon: Icon(Icons.sync)),
|
||||
),
|
||||
dropdownButton(context),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(hintText: 'Поиск'),
|
||||
onChanged: (v) {
|
||||
if (filter != v.toLowerCase()) {
|
||||
filter = v.toLowerCase();
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => EditCard(null, api).addCard(context),
|
||||
child: Container(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.add, size: 30),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (filteredCards.length >= (index - 1) * 3) {
|
||||
final cardIndex = (index - 1) * 3;
|
||||
return Row(
|
||||
children: [
|
||||
if (filteredCards.length > cardIndex)
|
||||
cardWidget(filteredCards[cardIndex]),
|
||||
if (filteredCards.length > cardIndex + 1)
|
||||
cardWidget(filteredCards[cardIndex + 1]),
|
||||
if (filteredCards.length > cardIndex + 2)
|
||||
cardWidget(filteredCards[cardIndex + 2]),
|
||||
],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
if (selectingMode)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
height: 100,
|
||||
padding: EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_selectedCards.clear();
|
||||
setState(() {});
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.blue,
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('${_selectedCards.length} '),
|
||||
Icon(Icons.clear)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_currentPack != null)
|
||||
if (_selectedCards.every((c) =>
|
||||
_currentPack!.addCardIds?.contains(c) ?? false))
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(width: 8.0),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final updated =
|
||||
_currentPack!.copyWith.removeCardIds(
|
||||
_selectedCards.toList(),
|
||||
);
|
||||
editPack(updated);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
color: Colors.blue,
|
||||
child: Text('Убрать из пака'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.0),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final updated =
|
||||
_currentPack!.copyWith.previewCards(
|
||||
_selectedCards.toList(),
|
||||
);
|
||||
editPack(updated);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
color: Colors.blue,
|
||||
child: Text('Как превью'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
MaterialButton(
|
||||
onPressed: () {
|
||||
final updated = _currentPack!.copyWith.addCardIds(
|
||||
_selectedCards.toList(),
|
||||
);
|
||||
editPack(updated);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
color: Colors.blue,
|
||||
child: Text('Добавить в пак'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget cardWidget(GameCardDto card) {
|
||||
void toggleSelect() {
|
||||
final id = card.id.toString();
|
||||
if (_selectedCards.contains(id)) {
|
||||
_selectedCards.remove(id);
|
||||
} else {
|
||||
_selectedCards.add(card.id.toString());
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (selectingMode) {
|
||||
toggleSelect();
|
||||
} else {
|
||||
EditCard(card, api).addCard(context);
|
||||
}
|
||||
},
|
||||
onLongPress: toggleSelect,
|
||||
child: _Card(card, api, _selectedCards.contains(card.id.toString())),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPackChanged(String? id) {
|
||||
if (id == null) {
|
||||
clearCurrentPack();
|
||||
} else {
|
||||
_currentId = id;
|
||||
loadCurrentPack();
|
||||
}
|
||||
}
|
||||
|
||||
Widget dropdownButton(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButton(
|
||||
items: [..._packPreviews, null]
|
||||
.map((p) => DropdownMenuItem<String?>(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: p == null
|
||||
? Text('Все паки')
|
||||
: Text('${p.title} ${p.cards}'),
|
||||
),
|
||||
value: p?.id,
|
||||
))
|
||||
.toList(),
|
||||
value: _currentId,
|
||||
onChanged: (v) => _onPackChanged(v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: addOrEditPack,
|
||||
icon: _currentPack == null ? Icon(Icons.add) : Icon(Icons.edit),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
final GameCardDto cardDto;
|
||||
final AdminApi api;
|
||||
final bool selected;
|
||||
|
||||
_Card(this.cardDto, this.api, this.selected);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = cardDto.image != null ? base64.decode(cardDto.image!) : null;
|
||||
return Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
height: 160.h,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: selected ? Colors.blue : borderGray),
|
||||
color: selected ? Colors.blue.withOpacity(0.2) : null,
|
||||
),
|
||||
child: Column(children: [
|
||||
Text(cardDto.translation ?? ''),
|
||||
Text(cardDto.original ?? ''),
|
||||
if (image != null)
|
||||
Expanded(
|
||||
child: Image.memory(
|
||||
key: ObjectKey(cardDto.image),
|
||||
gaplessPlayback: true,
|
||||
image,
|
||||
fit: BoxFit.scaleDown,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mnemo_cards/admin/add_user.dart';
|
||||
import 'package:mnemo_cards/admin/admin_api.dart';
|
||||
import 'package:mnemo_cards/di/injector.dart';
|
||||
import 'package:mnemo_cards/di/locator.dart';
|
||||
import 'package:mnemo_cards/managers/repository/dio_provider.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class AllUsers extends StatefulWidget {
|
||||
@override
|
||||
State<StatefulWidget> createState() => _AllUsersState();
|
||||
|
||||
const AllUsers();
|
||||
}
|
||||
|
||||
class _AllUsersState extends State<AllUsers> {
|
||||
static List<String> ids = [];
|
||||
static Set<UserDto> users = {};
|
||||
static String filter = '';
|
||||
static UserDto? _currentUser;
|
||||
static String? _currentId;
|
||||
|
||||
bool get selectingMode => false; //_selectedCards.isNotEmpty;
|
||||
|
||||
Dio get dio => getIt.get<DioProvider>().dio;
|
||||
|
||||
AdminApi get api => getIt.get<AdminApi>();
|
||||
|
||||
Future<void> loadUsers() async {
|
||||
var loadingIds = ids.toList();
|
||||
users.clear();
|
||||
while (loadingIds.isNotEmpty) {
|
||||
try {
|
||||
final ids = loadingIds.take(10).toList();
|
||||
loadingIds.removeRange(0, ids.length);
|
||||
final usersPage = await api.getUsers(ids);
|
||||
users.addAll(usersPage);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadIds() async {
|
||||
ids = await api.getUserIds();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> loadCurrentUser() async {
|
||||
if (_currentId != null) {
|
||||
_currentUser = (await api.getUsers([_currentId!])).firstOrNull;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void clearCurrentUser() {
|
||||
_currentId = null;
|
||||
_currentUser = null;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> editUser(UserDto user) async {
|
||||
final result = await api.editUser(user);
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('ERROR'),
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
|
||||
content: Text('Success!!!'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
List<UserDto> buildUsersList() {
|
||||
final filteredUsers = users.where((dto) {
|
||||
final filterOk = filter.isEmpty ||
|
||||
dto.name.toString().toLowerCase().contains(filter) ||
|
||||
dto.id.toString().toLowerCase().contains(filter) ||
|
||||
dto.packs.any((p) => p.toLowerCase().contains(filter));
|
||||
return filterOk;
|
||||
});
|
||||
return filteredUsers.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filteredUsers = buildUsersList();
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(itemBuilder: (context, index) {
|
||||
if (index == 0)
|
||||
return Container(
|
||||
height: 270,
|
||||
child: Column(
|
||||
children: [
|
||||
Header(
|
||||
_currentId == null
|
||||
? 'Все'
|
||||
: _currentUser?.name ?? 'Пак ${_currentUser?.id}',
|
||||
subtitle: filteredUsers.isEmpty
|
||||
? 'Нет пользователей'
|
||||
: '${filteredUsers.length} пользователя',
|
||||
hasPopButton: true,
|
||||
trail: IconButton(
|
||||
onPressed: () async {
|
||||
setState(() {});
|
||||
await loadIds();
|
||||
await loadUsers();
|
||||
},
|
||||
icon: Icon(Icons.sync)),
|
||||
),
|
||||
dropdownButton(context),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(hintText: 'Поиск'),
|
||||
onChanged: (v) {
|
||||
if (filter != v.toLowerCase()) {
|
||||
filter = v.toLowerCase();
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// Expanded(
|
||||
// child: GestureDetector(
|
||||
// onTap: () => EditUser(null, api).addCard(context),
|
||||
// child: Container(
|
||||
// color: Colors.green.withOpacity(0.1),
|
||||
// alignment: Alignment.center,
|
||||
// child: Icon(Icons.add, size: 30),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
);
|
||||
else if (filteredUsers.length > index - 1)
|
||||
return userWidget(filteredUsers[index - 1]);
|
||||
else
|
||||
return null;
|
||||
}),
|
||||
if (selectingMode)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
height: 100,
|
||||
padding: EdgeInsets.all(10.0),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {});
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.blue,
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [Icon(Icons.clear)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget userWidget(UserDto user) {
|
||||
void toggleSelect() {
|
||||
final id = user.id.toString();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (selectingMode) {
|
||||
toggleSelect();
|
||||
} else {
|
||||
EditUser(user, api).addUser(context);
|
||||
}
|
||||
},
|
||||
onLongPress: toggleSelect,
|
||||
child: _User(user, api, false),
|
||||
);
|
||||
}
|
||||
|
||||
Widget dropdownButton(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: null,
|
||||
icon: _currentUser == null ? Icon(Icons.add) : Icon(Icons.edit),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _User extends StatelessWidget {
|
||||
final UserDto userDto;
|
||||
final AdminApi api;
|
||||
final bool selected;
|
||||
|
||||
_User(this.userDto, this.api, this.selected);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCurrent = locator.userManager.userStateHolder.user?.id == userDto.id;
|
||||
return ListTile(
|
||||
title: Text('${userDto.id}${isCurrent ? ' (you)' : ''}'),
|
||||
subtitle: Text(userDto.name ?? ''),
|
||||
trailing: userDto.admin ? Text('admin') : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
class DateParam extends StatelessWidget {
|
||||
final String title;
|
||||
final DateTime? initial;
|
||||
final Function(DateTime? v) onChanged;
|
||||
|
||||
DateParam(this.title, this.initial, this.onChanged);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title),
|
||||
SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
initialDate: initial ?? DateTime.now(),
|
||||
firstDate: DateTime.utc(2000),
|
||||
lastDate: DateTime.utc(2050),
|
||||
).then((selectedDate) {
|
||||
// After selecting the date, display the time picker.
|
||||
if (selectedDate != null) {
|
||||
return showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
).then((selectedTime) {
|
||||
// Handle the selected date and time here.
|
||||
if (selectedTime != null) {
|
||||
DateTime selectedDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
return selectedDateTime;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
onChanged(date);
|
||||
},
|
||||
child: Text(
|
||||
initial?.let(
|
||||
(d) =>
|
||||
'${d.year}.${d.month}.${d.day} ${d.hour}:${d.minute}',
|
||||
) ??
|
||||
'No date',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class EnumParam<T> extends StatelessWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final T initial;
|
||||
final List<T> values;
|
||||
final Function(T v) onChanged;
|
||||
final Function(T v)? getName;
|
||||
|
||||
EnumParam(
|
||||
this.title,
|
||||
this.values,
|
||||
this.initial,
|
||||
this.onChanged, {
|
||||
this.description,
|
||||
this.getName,
|
||||
});
|
||||
|
||||
String _defaultName(T v) => ((v is Enum) ? v.name : v.toString());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
T value = initial;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title),
|
||||
if (description != null)
|
||||
Text(
|
||||
description!,
|
||||
style: TextStyle(fontSize: 12),
|
||||
)
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: 2,
|
||||
),
|
||||
Expanded(
|
||||
child: StatefulBuilder(builder: (context, setState) {
|
||||
return DropdownButton<T>(
|
||||
value: value,
|
||||
items: values
|
||||
.map(
|
||||
(v) => DropdownMenuItem(
|
||||
child: Text(getName?.call(v) ?? _defaultName(v)),
|
||||
value: v,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() {
|
||||
value = v!;
|
||||
onChanged(v);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class IntParam extends StatelessWidget {
|
||||
final String title;
|
||||
final int? initial;
|
||||
final Function(int? v) onChanged;
|
||||
|
||||
IntParam(this.title, this.initial, this.onChanged);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title+':'),
|
||||
SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller:
|
||||
TextEditingController(text: initial?.toString() ?? ''),
|
||||
onChanged: (v) => onChanged(int.tryParse(v)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TextParam extends StatelessWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final String? initial;
|
||||
final Function(String v) onChanged;
|
||||
|
||||
TextParam(this.title, this.initial, this.onChanged, {this.description});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title),
|
||||
if (description != null)
|
||||
Text(
|
||||
description!,
|
||||
style: TextStyle(fontSize: 12),
|
||||
)
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: 2,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: initial),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
|
@ -1,25 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# --- Build stage ------------------------------------------------------------
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Runtime stage ----------------------------------------------------------
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Replace default config with SPA-aware config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY --from=build /app/dist/ ./
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
# HTTP server (SSL will be configured later)
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name admin.mnemo-cards.online;
|
||||
#
|
||||
# # Redirect HTTP to HTTPS
|
||||
# return 301 https://$server_name$request_uri;
|
||||
# }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.mnemo-cards.online;
|
||||
|
||||
# Note: SSL certificate not yet configured - using HTTP for now
|
||||
# TODO: Set up Let's Encrypt certificate for HTTPS
|
||||
# ssl_certificate /etc/letsencrypt/live/admin.mnemo-cards.online/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/admin.mnemo-cards.online/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Security headers (enhanced for admin panel)
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.mnemo-cards.online;" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Root directory for admin panel
|
||||
root /var/www/mnemo_cards_admin;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
|
||||
|
||||
# Main location block
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1h; # Shorter cache for admin panel
|
||||
add_header Cache-Control "private, must-revalidate";
|
||||
}
|
||||
}
|
||||
|
||||
# Security - deny access to hidden files and sensitive paths
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ ^/admin/ {
|
||||
deny all; # Prevent access to any /admin/ paths that might be exposed
|
||||
}
|
||||
|
||||
# Rate limiting for admin panel
|
||||
limit_req zone=admin burst=10 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
# Log admin access
|
||||
access_log /var/log/nginx/admin_access.log;
|
||||
error_log /var/log/nginx/admin_error.log;
|
||||
}
|
||||
|
||||
# Rate limiting zone for admin panel
|
||||
limit_req_zone $binary_remote_addr zone=admin:10m rate=5r/m;
|
||||
19
mnemo_cards_telegram_bot/.dockerignore
Normal file
19
mnemo_cards_telegram_bot/.dockerignore
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.git
|
||||
.gitmodules
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
build
|
||||
.dart_tool
|
||||
pubspec.lock
|
||||
*.log
|
||||
*.tmp
|
||||
**/.DS_Store
|
||||
test/
|
||||
*.exe
|
||||
*.dylib
|
||||
*.so
|
||||
zips/
|
||||
backups/
|
||||
apks/
|
||||
61
mnemo_cards_telegram_bot/Dockerfile
Normal file
61
mnemo_cards_telegram_bot/Dockerfile
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# --- Build stage ------------------------------------------------------------
|
||||
# Note: This Dockerfile should be built from the parent directory:
|
||||
# docker build -f mnemo_cards_telegram_bot/Dockerfile -t mnemo_cards_telegram_bot .
|
||||
|
||||
FROM dart:stable-sdk AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all local dependencies
|
||||
COPY mnemo_cards_common /app/mnemo_cards_common
|
||||
COPY mnemo_cards_common_backend /app/mnemo_cards_common_backend
|
||||
COPY mnemo_cards_telegram_bot /app/mnemo_cards_telegram_bot
|
||||
|
||||
# Get dependencies (order matters due to path dependencies)
|
||||
WORKDIR /app/mnemo_cards_common
|
||||
RUN dart pub get
|
||||
|
||||
WORKDIR /app/mnemo_cards_common_backend
|
||||
RUN dart pub get
|
||||
|
||||
WORKDIR /app/mnemo_cards_telegram_bot
|
||||
RUN dart pub get
|
||||
|
||||
# Compile to native executable
|
||||
RUN dart compile exe bin/main.dart -o /app/bot
|
||||
|
||||
# --- Runtime stage ----------------------------------------------------------
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy bot binary
|
||||
COPY --from=build /app/bot /app/bot
|
||||
|
||||
# Copy required files
|
||||
COPY --from=build /app/mnemo_cards_telegram_bot/admins /app/admins
|
||||
|
||||
# Create required directories
|
||||
RUN mkdir -p /app/backups /app/zips /app/apks && \
|
||||
chmod -R 755 /app/backups /app/zips /app/apks
|
||||
|
||||
# Environment variables
|
||||
ENV BACKEND_URL=https://api.mnemo-cards.online \
|
||||
BOT_SHARE_DAILY_LIMIT=1
|
||||
|
||||
# No EXPOSE needed - bot uses outbound connections only (polling)
|
||||
|
||||
# Healthcheck - check if process is running
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD pgrep -f "/app/bot" > /dev/null || exit 1
|
||||
|
||||
# Start bot
|
||||
CMD ["/app/bot"]
|
||||
Loading…
Reference in a new issue