Initial commit

This commit is contained in:
2026-07-26 21:34:07 -06:00
commit c48c25769a
95 changed files with 14260 additions and 0 deletions

57
.gitignore vendored Normal file
View File

@@ -0,0 +1,57 @@
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Version control
.svn/
# OS-specific files
.DS_Store
Thumbs.db
# Project Specific
bugs-encontrados.txt
opencode.txt
DocmostApp.md
# App build
app/build/

32
README.md Normal file
View File

@@ -0,0 +1,32 @@
# Docmost App
Cliente Android no oficial para [Docmost](https://docmost.com) — una wiki colaborativa de código abierto.
## Funcionalidades
- **Autenticación por cookie** — login contra servidor Docmost Community Edition
- **Lista de espacios** — espacios expandibles con páginas en sidebar
- **Editor Markdown** — edición de páginas con TipTap → Markdown, toggle Edit/Preview con Markwon
- **Iconos de espacio** — carga y muestra desde el servidor (OkHttp + cache Lru)
- **Editor de espacio** — modificar nombre, descripción, icono (subir/eliminar)
- **Emojis de página** — muestra el `icon` emoji de cada página
- **UI Material 3** — MaterialCardView, Dynamic Colors, tema en español
## Requisitos
- Android 8.0 (API 26) o superior
- Servidor Docmost (Community Edition) accesible desde el dispositivo
## Compilación
```bash
./gradlew assembleDebug
```
## Capturas de pantalla
*(pendiente)*
## Licencia
MIT

80
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,80 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
kotlin("kapt")
}
android {
namespace = "com.docmost.app"
compileSdk = 34
defaultConfig {
applicationId = "com.docmost.app"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
// Encrypted SharedPreferences
implementation("androidx.security:security-crypto:1.0.0")
// SwipeRefreshLayout
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Markwon - Markdown rendering
implementation("io.noties.markwon:core:4.6.2")
implementation("io.noties.markwon:ext-strikethrough:4.6.2")
implementation("io.noties.markwon:ext-tables:4.6.2")
implementation("io.noties.markwon:ext-tasklist:4.6.2")
// Coil - Image loading
implementation("io.coil-kt:coil:2.7.0")
// Room - Local database for sync queue
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// WorkManager - Background sync
implementation("androidx.work:work-runtime-ktx:2.9.0")
}

6
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,6 @@
# Add project specific ProGuard rules here.
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.docmost.app.api.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<application
android:name=".DocmostApp"
android:allowBackup="true"
android:icon="@drawable/ic_docmost"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Docmost"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.LoginActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.SpacesActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.PageEditorActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize"
android:configChanges="uiMode|orientation|screenSize" />
<activity
android:name=".ui.VersionViewerActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize"
android:configChanges="uiMode|orientation|screenSize" />
</application>
</manifest>

View File

@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.cdx-checklist{gap:6px;display:flex;flex-direction:column}.cdx-checklist__item{display:flex;box-sizing:content-box;align-items:flex-start}.cdx-checklist__item-text{outline:none;flex-grow:1;line-height:1.57em}.cdx-checklist__item-checkbox{width:22px;height:22px;display:flex;align-items:center;margin-right:8px;margin-top:calc(.785em - 11px);cursor:pointer}.cdx-checklist__item-checkbox svg{opacity:0;height:20px;width:20px;position:absolute;left:-1px;top:-1px;max-height:20px}@media (hover: hover){.cdx-checklist__item-checkbox:not(.cdx-checklist__item-checkbox--no-hover):hover .cdx-checklist__item-checkbox-check svg{opacity:1}}.cdx-checklist__item-checkbox-check{cursor:pointer;display:inline-block;flex-shrink:0;position:relative;width:20px;height:20px;box-sizing:border-box;margin-left:0;border-radius:5px;border:1px solid #C9C9C9;background:#fff}.cdx-checklist__item-checkbox-check:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:100%;background-color:#369fff;visibility:hidden;pointer-events:none;transform:scale(1);transition:transform .4s ease-out,opacity .4s}@media (hover: hover){.cdx-checklist__item--checked .cdx-checklist__item-checkbox:not(.cdx-checklist__item--checked .cdx-checklist__item-checkbox--no-hover):hover .cdx-checklist__item-checkbox-check{background:#0059AB;border-color:#0059ab}}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check{background:#369FFF;border-color:#369fff}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check svg{opacity:1}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check svg path{stroke:#fff}.cdx-checklist__item--checked .cdx-checklist__item-checkbox-check:before{opacity:0;visibility:visible;transform:scale(2.5)}')),document.head.appendChild(e)}}catch(c){console.error("vite-plugin-css-injected-by-js",c)}})();
(function(l,c){typeof exports=="object"&&typeof module<"u"?module.exports=c():typeof define=="function"&&define.amd?define(c):(l=typeof globalThis<"u"?globalThis:l||self,l.Checklist=c())})(this,function(){"use strict";const l="",c='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M7 12L10.4884 15.8372C10.5677 15.9245 10.705 15.9245 10.7844 15.8372L17 9"/></svg>',g='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9.2 12L11.0586 13.8586C11.1367 13.9367 11.2633 13.9367 11.3414 13.8586L14.7 10.5"/><rect width="14" height="14" x="5" y="5" stroke="currentColor" stroke-width="2" rx="4"/></svg>';function d(){const s=document.activeElement,t=window.getSelection().getRangeAt(0),n=t.cloneRange();return n.selectNodeContents(s),n.setStart(t.endContainer,t.endOffset),n.extractContents()}function f(s){const e=document.createElement("div");return e.appendChild(s),e.innerHTML}function o(s,e=null,t={}){const n=document.createElement(s);Array.isArray(e)?n.classList.add(...e):e&&n.classList.add(e);for(const i in t)n[i]=t[i];return n}function m(s){return s.innerHTML.replace("<br>"," ").trim()}function p(s,e=!1,t=void 0){const n=document.createRange(),i=window.getSelection();n.selectNodeContents(s),t!==void 0&&(n.setStart(s,t),n.setEnd(s,t)),n.collapse(e),i.removeAllRanges(),i.addRange(n)}Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(s){let e=this;if(!document.documentElement.contains(e))return null;do{if(e.matches(s))return e;e=e.parentElement||e.parentNode}while(e!==null&&e.nodeType===1);return null});class C{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}static get toolbox(){return{icon:g,title:"Checklist"}}static get conversionConfig(){return{export:e=>e.items.map(({text:t})=>t).join(". "),import:e=>({items:[{text:e,checked:!1}]})}}constructor({data:e,config:t,api:n,readOnly:i}){this._elements={wrapper:null,items:[]},this.readOnly=i,this.api=n,this.data=e||{}}render(){return this._elements.wrapper=o("div",[this.CSS.baseBlock,this.CSS.wrapper]),this.data.items||(this.data.items=[{text:"",checked:!1}]),this.data.items.forEach(e=>{const t=this.createChecklistItem(e);this._elements.wrapper.appendChild(t)}),this.readOnly?this._elements.wrapper:(this._elements.wrapper.addEventListener("keydown",e=>{const[t,n]=[13,8];switch(e.keyCode){case t:this.enterPressed(e);break;case n:this.backspace(e);break}},!1),this._elements.wrapper.addEventListener("click",e=>{this.toggleCheckbox(e)}),this._elements.wrapper)}save(){let e=this.items.map(t=>{const n=this.getItemInput(t);return{text:m(n),checked:t.classList.contains(this.CSS.itemChecked)}});return e=e.filter(t=>t.text.trim().length!==0),{items:e}}validate(e){return!!e.items.length}toggleCheckbox(e){const t=e.target.closest(`.${this.CSS.item}`),n=t.querySelector(`.${this.CSS.checkboxContainer}`);n.contains(e.target)&&(t.classList.toggle(this.CSS.itemChecked),n.classList.add(this.CSS.noHover),n.addEventListener("mouseleave",()=>this.removeSpecialHoverBehavior(n),{once:!0}))}createChecklistItem(e={}){const t=o("div",this.CSS.item),n=o("span",this.CSS.checkbox),i=o("div",this.CSS.checkboxContainer),a=o("div",this.CSS.textField,{innerHTML:e.text?e.text:"",contentEditable:!this.readOnly});return e.checked&&t.classList.add(this.CSS.itemChecked),n.innerHTML=c,i.appendChild(n),t.appendChild(i),t.appendChild(a),t}enterPressed(e){e.preventDefault();const t=this.items,n=document.activeElement.closest(`.${this.CSS.item}`);if(t.indexOf(n)===t.length-1&&m(this.getItemInput(n)).length===0){const x=this.api.blocks.getCurrentBlockIndex();n.remove(),this.api.blocks.insert(),this.api.caret.setToBlock(x+1);return}const u=d(),h=f(u),r=this.createChecklistItem({text:h,checked:!1});this._elements.wrapper.insertBefore(r,n.nextSibling),p(this.getItemInput(r),!0)}backspace(e){const t=e.target.closest(`.${this.CSS.item}`),n=this.items.indexOf(t),i=this.items[n-1];if(!i||!(window.getSelection().focusOffset===0))return;e.preventDefault();const h=d(),r=this.getItemInput(i),k=r.childNodes.length;r.appendChild(h),p(r,void 0,k),t.remove()}get CSS(){return{baseBlock:this.api.styles.block,wrapper:"cdx-checklist",item:"cdx-checklist__item",itemChecked:"cdx-checklist__item--checked",noHover:"cdx-checklist__item-checkbox--no-hover",checkbox:"cdx-checklist__item-checkbox-check",textField:"cdx-checklist__item-text",checkboxContainer:"cdx-checklist__item-checkbox"}}get items(){return Array.from(this._elements.wrapper.querySelectorAll(`.${this.CSS.item}`))}removeSpecialHoverBehavior(e){e.classList.remove(this.CSS.noHover)}getItemInput(e){return e.querySelector(`.${this.CSS.textField}`)}}return C});

View File

@@ -0,0 +1,11 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-code__textarea{min-height:200px;font-family:Menlo,Monaco,Consolas,Courier New,monospace;color:#41314e;line-height:1.6em;font-size:12px;background:#f8f7fa;border:1px solid #f1f1f4;box-shadow:none;white-space:pre;word-wrap:normal;overflow-x:auto;resize:vertical}")),document.head.appendChild(e)}}catch(o){console.error("vite-plugin-css-injected-by-js",o)}})();
(function(o,i){typeof exports=="object"&&typeof module<"u"?module.exports=i():typeof define=="function"&&define.amd?define(i):(o=typeof globalThis<"u"?globalThis:o||self,o.CodeTool=i())})(this,function(){"use strict";const o="";function i(u,t){let s="";for(;s!==`
`&&t>0;)t=t-1,s=u.substr(t,1);return s===`
`&&(t+=1),t}const h='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 8L5 12L9 16"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8L19 12L15 16"/></svg>';/**
* CodeTool for Editor.js
*
* @author CodeX (team@ifmo.su)
* @copyright CodeX 2018
* @license MIT
* @version 2.0.0
*/class c{static get isReadOnlySupported(){return!0}static get enableLineBreaks(){return!0}constructor({data:t,config:e,api:s,readOnly:n}){this.api=s,this.readOnly=n,this.placeholder=this.api.i18n.t(e.placeholder||c.DEFAULT_PLACEHOLDER),this.CSS={baseClass:this.api.styles.block,input:this.api.styles.input,wrapper:"ce-code",textarea:"ce-code__textarea"},this.nodes={holder:null,textarea:null},this.data={code:t.code||""},this.nodes.holder=this.drawView()}drawView(){const t=document.createElement("div"),e=document.createElement("textarea");return t.classList.add(this.CSS.baseClass,this.CSS.wrapper),e.classList.add(this.CSS.textarea,this.CSS.input),e.textContent=this.data.code,e.placeholder=this.placeholder,this.readOnly&&(e.disabled=!0),t.appendChild(e),e.addEventListener("keydown",s=>{switch(s.code){case"Tab":this.tabHandler(s);break}}),this.nodes.textarea=e,t}render(){return this.nodes.holder}save(t){return{code:t.querySelector("textarea").value}}onPaste(t){const e=t.detail.data;this.data={code:e.textContent}}get data(){return this._data}set data(t){this._data=t,this.nodes.textarea&&(this.nodes.textarea.textContent=t.code)}static get toolbox(){return{icon:h,title:"Code"}}static get DEFAULT_PLACEHOLDER(){return"Enter a code"}static get pasteConfig(){return{tags:["pre"]}}static get sanitize(){return{code:!0}}tabHandler(t){t.stopPropagation(),t.preventDefault();const e=t.target,s=t.shiftKey,n=e.selectionStart,r=e.value,a=" ";let d;if(!s)d=n+a.length,e.value=r.substring(0,n)+a+r.substring(n);else{const l=i(r,n);if(r.substr(l,a.length)!==a)return;e.value=r.substring(0,l)+r.substring(l+a.length),d=n-a.length}e.setSelectionRange(d,d)}}return c});

View File

@@ -0,0 +1,9 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode('.ce-delimiter{line-height:1.6em;width:100%;text-align:center}.ce-delimiter:before{display:inline-block;content:"***";font-size:30px;line-height:65px;height:30px;letter-spacing:.2em}')),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})();
(function(i,e){typeof exports=="object"&&typeof module<"u"?module.exports=e():typeof define=="function"&&define.amd?define(e):(i=typeof globalThis<"u"?globalThis:i||self,i.Delimiter=e())})(this,function(){"use strict";const i="",e='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><line x1="6" x2="10" y1="12" y2="12" stroke="currentColor" stroke-linecap="round" stroke-width="2"/><line x1="14" x2="18" y1="12" y2="12" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';/**
* Delimiter Block for the Editor.js.
*
* @author CodeX (team@ifmo.su)
* @copyright CodeX 2018
* @license The MIT License (MIT)
* @version 2.0.0
*/class r{static get isReadOnlySupported(){return!0}static get contentless(){return!0}constructor({data:t,config:o,api:n}){this.api=n,this._CSS={block:this.api.styles.block,wrapper:"ce-delimiter"},this._data={},this._element=this.drawView(),this.data=t}drawView(){let t=document.createElement("DIV");return t.classList.add(this._CSS.wrapper,this._CSS.block),t}render(){return this._element}save(t){return{}}static get toolbox(){return{icon:e,title:"Delimiter"}}}return r});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-header{padding:.6em 0 3px;margin:0;line-height:1.25em;outline:none}.ce-header p,.ce-header div{padding:0!important;margin:0!important}.ce-header[contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;display:none;cursor:text}.ce-header[contentEditable=true][data-placeholder]:empty:before{display:block}.ce-header[contentEditable=true][data-placeholder]:empty:focus:before{display:none}")),document.head.appendChild(e)}}catch(t){console.error("vite-plugin-css-injected-by-js",t)}})();
(function(n,r){typeof exports=="object"&&typeof module<"u"?module.exports=r():typeof define=="function"&&define.amd?define(r):(n=typeof globalThis<"u"?globalThis:n||self,n.Header=r())})(this,function(){"use strict";const n="",r='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M19 17V10.2135C19 10.1287 18.9011 10.0824 18.836 10.1367L16 12.5"/></svg>',o='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 11C16 10 19 9.5 19 12C19 13.9771 16.0684 13.9997 16.0012 16.8981C15.9999 16.9533 16.0448 17 16.1 17L19.3 17"/></svg>',a='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 11C16 10.5 16.8323 10 17.6 10C18.3677 10 19.5 10.311 19.5 11.5C19.5 12.5315 18.7474 12.9022 18.548 12.9823C18.5378 12.9864 18.5395 13.0047 18.5503 13.0063C18.8115 13.0456 20 13.3065 20 14.8C20 16 19.5 17 17.8 17C17.8 17 16 17 16 16.3"/></svg>',h='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M18 10L15.2834 14.8511C15.246 14.9178 15.294 15 15.3704 15C16.8489 15 18.7561 15 20.2 15M19 17C19 15.7187 19 14.8813 19 13.6"/></svg>',d='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 15.9C16 15.9 16.3768 17 17.8 17C19.5 17 20 15.6199 20 14.7C20 12.7323 17.6745 12.0486 16.1635 12.9894C16.094 13.0327 16 12.9846 16 12.9027V10.1C16 10.0448 16.0448 10 16.1 10H19.8"/></svg>',u='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M19.5 10C16.5 10.5 16 13.3285 16 15M16 15V15C16 16.1046 16.8954 17 18 17H18.3246C19.3251 17 20.3191 16.3492 20.2522 15.3509C20.0612 12.4958 16 12.6611 16 15Z"/></svg>',g='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9 7L9 12M9 17V12M9 12L15 12M15 7V12M15 17L15 12"/></svg>';/**
* Header block for the Editor.js.
*
* @author CodeX (team@ifmo.su)
* @copyright CodeX 2018
* @license MIT
* @version 2.0.0
*/class c{constructor({data:e,config:t,api:s,readOnly:i}){this.api=s,this.readOnly=i,this._CSS={block:this.api.styles.block,wrapper:"ce-header"},this._settings=t,this._data=this.normalizeData(e),this._element=this.getTag()}normalizeData(e){const t={};return typeof e!="object"&&(e={}),t.text=e.text||"",t.level=parseInt(e.level)||this.defaultLevel.number,t}render(){return this._element}renderSettings(){return this.levels.map(e=>({icon:e.svg,label:this.api.i18n.t(`Heading ${e.number}`),onActivate:()=>this.setLevel(e.number),closeOnActivate:!0,isActive:this.currentLevel.number===e.number}))}setLevel(e){this.data={level:e,text:this.data.text}}merge(e){const t={text:this.data.text+e.text,level:this.data.level};this.data=t}validate(e){return e.text.trim()!==""}save(e){return{text:e.innerHTML,level:this.currentLevel.number}}static get conversionConfig(){return{export:"text",import:"text"}}static get sanitize(){return{level:!1,text:{}}}static get isReadOnlySupported(){return!0}get data(){return this._data.text=this._element.innerHTML,this._data.level=this.currentLevel.number,this._data}set data(e){if(this._data=this.normalizeData(e),e.level!==void 0&&this._element.parentNode){const t=this.getTag();t.innerHTML=this._element.innerHTML,this._element.parentNode.replaceChild(t,this._element),this._element=t}e.text!==void 0&&(this._element.innerHTML=this._data.text||"")}getTag(){const e=document.createElement(this.currentLevel.tag);return e.innerHTML=this._data.text||"",e.classList.add(this._CSS.wrapper),e.contentEditable=this.readOnly?"false":"true",e.dataset.placeholder=this.api.i18n.t(this._settings.placeholder||""),e}get currentLevel(){let e=this.levels.find(t=>t.number===this._data.level);return e||(e=this.defaultLevel),e}get defaultLevel(){if(this._settings.defaultLevel){const e=this.levels.find(t=>t.number===this._settings.defaultLevel);if(e)return e;console.warn("(ง'̀-'́)ง Heading Tool: the default level specified was not found in available levels")}return this.levels[1]}get levels(){const e=[{number:1,tag:"H1",svg:r},{number:2,tag:"H2",svg:o},{number:3,tag:"H3",svg:a},{number:4,tag:"H4",svg:h},{number:5,tag:"H5",svg:d},{number:6,tag:"H6",svg:u}];return this._settings.levels?e.filter(t=>this._settings.levels.includes(t.number)):e}onPaste(e){const t=e.detail.data;let s=this.defaultLevel.number;switch(t.tagName){case"H1":s=1;break;case"H2":s=2;break;case"H3":s=3;break;case"H4":s=4;break;case"H5":s=5;break;case"H6":s=6;break}this._settings.levels&&(s=this._settings.levels.reduce((i,l)=>Math.abs(l-s)<Math.abs(i-s)?l:i)),this.data={level:s,text:t.innerHTML}}static get pasteConfig(){return{tags:["H1","H2","H3","H4","H5","H6"]}}static get toolbox(){return{icon:g,title:"Heading"}}}return c});

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".inline-code{background:rgba(250,239,240,.78);color:#b44437;padding:3px 4px;border-radius:5px;margin:0 1px;font-family:inherit;font-size:.86em;font-weight:500;letter-spacing:.3px}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})();
(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.InlineCode=s())})(this,function(){"use strict";const i="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9.5 8L6.11524 11.8683C6.04926 11.9437 6.04926 12.0563 6.11524 12.1317L9.5 16"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M15 8L18.3848 11.8683C18.4507 11.9437 18.4507 12.0563 18.3848 12.1317L15 16"/></svg>';class n{static get CSS(){return"inline-code"}constructor({api:t}){this.api=t,this.button=null,this.tag="CODE",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{code:{class:n.CSS}}}}return n});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".cdx-marker{background:rgba(245,235,111,.29);padding:3px 0}")),document.head.appendChild(e)}}catch(d){console.error("vite-plugin-css-injected-by-js",d)}})();
(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.Marker=s())})(this,function(){"use strict";const i="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" d="M11.3536 9.31802L12.7678 7.90381C13.5488 7.12276 14.8151 7.12276 15.5962 7.90381C16.3772 8.68486 16.3772 9.95119 15.5962 10.7322L14.182 12.1464M11.3536 9.31802L7.96729 12.7043C7.40889 13.2627 7.02827 13.9739 6.8734 14.7482L6.69798 15.6253C6.55804 16.325 7.17496 16.942 7.87468 16.802L8.75176 16.6266C9.52612 16.4717 10.2373 16.0911 10.7957 15.5327L14.182 12.1464M11.3536 9.31802L14.182 12.1464"/><line x1="15" x2="19" y1="17" y2="17" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';class n{static get CSS(){return"cdx-marker"}constructor({api:t}){this.api=t,this.button=null,this.tag="MARK",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{mark:{class:n.CSS}}}}return n});

View File

@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var t=document.createElement("style");t.appendChild(document.createTextNode(".cdx-quote-icon svg{transform:rotate(180deg)}.cdx-quote{margin:0}.cdx-quote__text{min-height:158px;margin-bottom:10px}.cdx-quote [contentEditable=true][data-placeholder]:before{position:absolute;content:attr(data-placeholder);color:#707684;font-weight:400;opacity:0}.cdx-quote [contentEditable=true][data-placeholder]:empty:before{opacity:1}.cdx-quote [contentEditable=true][data-placeholder]:empty:focus:before{opacity:0}.cdx-quote-settings{display:flex}.cdx-quote-settings .cdx-settings-button{width:50%}")),document.head.appendChild(t)}}catch(e){console.error("vite-plugin-css-injected-by-js",e)}})();
(function(s,o){typeof exports=="object"&&typeof module<"u"?module.exports=o():typeof define=="function"&&define.amd?define(o):(s=typeof globalThis<"u"?globalThis:s||self,s.Quote=o())})(this,function(){"use strict";const s="",o='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M18 7L6 7"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M18 17H6"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 12L8 12"/></svg>',c='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M17 7L5 7"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M17 17H5"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M13 12L5 12"/></svg>',l='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10.8182L9 10.8182C8.80222 10.8182 8.60888 10.7649 8.44443 10.665C8.27998 10.5651 8.15181 10.4231 8.07612 10.257C8.00043 10.0909 7.98063 9.90808 8.01922 9.73174C8.0578 9.55539 8.15304 9.39341 8.29289 9.26627C8.43275 9.13913 8.61093 9.05255 8.80491 9.01747C8.99889 8.98239 9.19996 9.00039 9.38268 9.0692C9.56541 9.13801 9.72159 9.25453 9.83147 9.40403C9.94135 9.55353 10 9.72929 10 9.90909L10 12.1818C10 12.664 9.78929 13.1265 9.41421 13.4675C9.03914 13.8084 8.53043 14 8 14"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 10.8182L15 10.8182C14.8022 10.8182 14.6089 10.7649 14.4444 10.665C14.28 10.5651 14.1518 10.4231 14.0761 10.257C14.0004 10.0909 13.9806 9.90808 14.0192 9.73174C14.0578 9.55539 14.153 9.39341 14.2929 9.26627C14.4327 9.13913 14.6109 9.05255 14.8049 9.01747C14.9989 8.98239 15.2 9.00039 15.3827 9.0692C15.5654 9.13801 15.7216 9.25453 15.8315 9.40403C15.9414 9.55353 16 9.72929 16 9.90909L16 12.1818C16 12.664 15.7893 13.1265 15.4142 13.4675C15.0391 13.8084 14.5304 14 14 14"/></svg>';class i{static get isReadOnlySupported(){return!0}static get toolbox(){return{icon:l,title:"Quote"}}static get contentless(){return!0}static get enableLineBreaks(){return!0}static get DEFAULT_QUOTE_PLACEHOLDER(){return"Enter a quote"}static get DEFAULT_CAPTION_PLACEHOLDER(){return"Enter a caption"}static get ALIGNMENTS(){return{left:"left",center:"center"}}static get DEFAULT_ALIGNMENT(){return i.ALIGNMENTS.left}static get conversionConfig(){return{import:"text",export:function(t){return t.caption?`${t.text}${t.caption}`:t.text}}}get CSS(){return{baseClass:this.api.styles.block,wrapper:"cdx-quote",text:"cdx-quote__text",input:this.api.styles.input,caption:"cdx-quote__caption"}}get settings(){return[{name:"left",icon:c},{name:"center",icon:o}]}constructor({data:t,config:e,api:n,readOnly:r}){const{ALIGNMENTS:a,DEFAULT_ALIGNMENT:d}=i;this.api=n,this.readOnly=r,this.quotePlaceholder=e.quotePlaceholder||i.DEFAULT_QUOTE_PLACEHOLDER,this.captionPlaceholder=e.captionPlaceholder||i.DEFAULT_CAPTION_PLACEHOLDER,this.data={text:t.text||"",caption:t.caption||"",alignment:Object.values(a).includes(t.alignment)&&t.alignment||e.defaultAlignment||d}}render(){const t=this._make("blockquote",[this.CSS.baseClass,this.CSS.wrapper]),e=this._make("div",[this.CSS.input,this.CSS.text],{contentEditable:!this.readOnly,innerHTML:this.data.text}),n=this._make("div",[this.CSS.input,this.CSS.caption],{contentEditable:!this.readOnly,innerHTML:this.data.caption});return e.dataset.placeholder=this.quotePlaceholder,n.dataset.placeholder=this.captionPlaceholder,t.appendChild(e),t.appendChild(n),t}save(t){const e=t.querySelector(`.${this.CSS.text}`),n=t.querySelector(`.${this.CSS.caption}`);return Object.assign(this.data,{text:e.innerHTML,caption:n.innerHTML})}static get sanitize(){return{text:{br:!0},caption:{br:!0},alignment:{}}}renderSettings(){const t=e=>e[0].toUpperCase()+e.substr(1);return this.settings.map(e=>({icon:e.icon,label:this.api.i18n.t(`Align ${t(e.name)}`),onActivate:()=>this._toggleTune(e.name),isActive:this.data.alignment===e.name,closeOnActivate:!0}))}_toggleTune(t){this.data.alignment=t}_make(t,e=null,n={}){const r=document.createElement(t);Array.isArray(e)?r.classList.add(...e):e&&r.classList.add(e);for(const a in n)r[a]=n[a];return r}}return i});

View File

@@ -0,0 +1,253 @@
:root {
--bg-color: #ffffff;
--text-color: #000000;
--code-bg: #f6f8fa;
--border-color: #e5e7eb;
--table-header-bg: #f9fafb;
--scrollbar-track: #f1f1f1;
--scrollbar-thumb: #c1c1c1;
--scrollbar-thumb-hover: #a1a1a1;
--placeholder-color: #9ca3af;
--quote-color: #4b5563;
--caption-color: #6b7280;
}
html.dark-mode {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--code-bg: #2d2d2d;
--border-color: #404040;
--table-header-bg: #2a2a2a;
--scrollbar-track: #2d2d2d;
--scrollbar-thumb: #555555;
--scrollbar-thumb-hover: #666666;
--placeholder-color: #6b7280;
--quote-color: #9ca3af;
--caption-color: #9ca3af;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s, color 0.3s;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Editor.js container */
#editorjs {
min-height: calc(100vh - 100px);
padding: 16px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.ce-block__content {
max-width: 100%;
}
.ce-toolbar__content {
max-width: 100%;
}
/* Editor.js table styles */
.tc-table {
display: block;
max-width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
.tc-row {
position: relative;
}
.tc-table__cell {
border: 1px solid var(--border-color);
padding: 8px 12px;
min-width: 50px;
color: var(--text-color);
position: relative;
}
.tc-table__header {
background: var(--table-header-bg);
font-weight: 600;
color: var(--text-color);
}
/* Editor.js code block */
.ce-code {
background: var(--code-bg);
color: var(--text-color);
border-radius: 6px;
padding: 16px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 14px;
line-height: 1.5;
overflow-x: auto;
}
/* Editor.js quote */
.ce-quote {
border-left: 3px solid var(--border-color);
padding-left: 16px;
margin: 0.5em 0;
color: var(--quote-color);
}
/* Editor.js checklist */
.ce-checklist__item {
display: flex;
align-items: center;
margin: 0.25em 0;
}
.ce-checklist__item-checkbox {
margin-right: 8px;
}
/* Undo/Redo floating buttons */
.undo-redo-container {
position: fixed;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 1000;
pointer-events: none;
}
.undo-btn, .redo-btn {
pointer-events: auto;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-color) 85%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0.65;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
cursor: pointer;
transition: opacity 0.2s, background 0.2s;
color: var(--text-color);
-webkit-tap-highlight-color: transparent;
user-select: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
padding: 0;
line-height: 1;
}
html.dark-mode .undo-btn,
html.dark-mode .redo-btn {
background: color-mix(in srgb, var(--bg-color) 80%, transparent);
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.undo-btn:active, .redo-btn:active {
opacity: 0.9;
background: var(--border-color);
}
.undo-btn[disabled], .redo-btn[disabled] {
opacity: 0.15;
pointer-events: none;
}
/* Editor.js delimiter */
.ce-delimiter {
margin: 1.5em 0;
border: none;
border-top: 2px solid var(--border-color);
padding: 0;
height: 0;
overflow: hidden;
}
.ce-delimiter::before,
.ce-delimiter::after {
display: none !important;
}
.ce-delimiter * {
display: none !important;
}
.ce-delimiter__content {
display: none !important;
}
.ce-delimiter__content * {
display: none !important;
}
/* Editor.js header styles */
.ce-header[data-item='1'],
.ce-header[style*="font-size: 32px"],
.ce-header h1,
[data-tool="header1"] .ce-header {
font-size: 2em;
font-weight: 700;
margin: 0.67em 0;
}
.ce-header[data-item='2'],
.ce-header[style*="font-size: 24px"],
.ce-header h2,
[data-tool="header2"] .ce-header {
font-size: 1.5em;
font-weight: 600;
margin: 0.75em 0;
}
.ce-header[data-item='3'],
.ce-header[style*="font-size: 20px"],
.ce-header h3,
[data-tool="header3"] .ce-header {
font-size: 1.25em;
font-weight: 600;
margin: 0.83em 0;
}
/* Custom File Tool (Attachments) */
.custom-file-tool {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
margin: 8px 0;
background-color: var(--code-bg);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
package com.docmost.app
import android.app.Application
class DocmostApp : Application()

View File

@@ -0,0 +1,368 @@
package com.docmost.app.api
import android.content.Context
import android.net.Uri
import android.util.Log
import com.docmost.app.utils.NetworkConnectivityObserver
import com.docmost.app.data.PageCacheManager
import com.docmost.app.data.SpaceCacheManager
import com.docmost.app.data.SyncManager
import java.io.File
import java.io.IOException
class CachedApi(
private val api: DocmostApi,
private val pageCache: PageCacheManager,
private val spaceCache: SpaceCacheManager,
private val syncManager: SyncManager,
private val networkObserver: NetworkConnectivityObserver
) {
var forcedOffline: Boolean = false
val isOffline: Boolean
get() = forcedOffline || networkObserver.isOffline()
private fun <T> cacheOrThrow(block: () -> T): T {
return try {
block()
} catch (e: Exception) {
throw OfflineNotAvailableException("No disponible sin conexión: ${e.message}")
}
}
// ── Spaces ──
suspend fun getSpaces(): List<Space> {
if (isOffline) {
val cached = spaceCache.getCachedSpaces()
if (cached.isNotEmpty()) return cached
throw OfflineNotAvailableException("No hay espacios en caché")
}
val spaces = api.getSpaces()
spaceCache.cacheSpaces(spaces)
return spaces
}
suspend fun createSpace(name: String, slug: String, description: String?): Space {
if (isOffline) {
val tempId = "temp_${System.currentTimeMillis()}"
syncManager.enqueueCreateSpace(name, slug, description, tempId)
val localSpace = Space(
id = tempId,
name = name,
description = description
)
spaceCache.cacheSpace(localSpace)
return localSpace
}
val space = api.createSpace(name, slug, description)
spaceCache.cacheSpace(space)
return space
}
suspend fun updateSpace(spaceId: String, name: String?, description: String?): Space {
if (isOffline) {
syncManager.enqueueUpdateSpace(spaceId, name, description)
val cached = spaceCache.getCachedSpace(spaceId)
if (cached != null) {
val updated = cached.copy(
name = name ?: cached.name,
description = description ?: cached.description
)
spaceCache.cacheSpace(updated)
return updated
}
throw OfflineNotAvailableException("Espacio no encontrado en caché")
}
val space = api.updateSpace(spaceId, name, description)
spaceCache.cacheSpace(space)
return space
}
suspend fun deleteSpace(spaceId: String) {
if (isOffline) {
syncManager.enqueueDeleteSpace(spaceId)
spaceCache.removeCachedSpace(spaceId)
return
}
api.deleteSpace(spaceId)
spaceCache.removeCachedSpace(spaceId)
}
suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String {
if (isOffline) {
throw OfflineNotAvailableException("Subir iconos no disponible sin conexión")
}
return api.uploadSpaceIcon(spaceId, imageUri, context)
}
suspend fun removeSpaceIcon(spaceId: String) {
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
api.removeSpaceIcon(spaceId)
}
// ── Pages ──
suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> {
if (isOffline) {
val cached = pageCache.getCachedPagesForSpace(spaceId)
if (cached != null) return cached
return emptyList()
}
val pages = api.getSidebarPages(spaceId, pageId)
pageCache.cachePagesForSpace(spaceId, pages)
return pages
}
suspend fun getPageInfo(pageId: String): Page {
if (isOffline) {
val cached = pageCache.getCachedPage(pageId)
if (cached != null) return cached
throw OfflineNotAvailableException("Página no encontrada en caché")
}
val page = api.getPageInfo(pageId)
pageCache.cachePage(page)
return page
}
suspend fun createPage(title: String, spaceId: String): Page {
if (isOffline) {
val tempId = "temp_${System.currentTimeMillis()}"
syncManager.enqueueCreatePage(title, spaceId, tempId)
val localPage = Page(
id = tempId,
title = title,
spaceId = spaceId
)
pageCache.cachePage(localPage)
val existing = pageCache.getCachedPagesForSpace(spaceId)?.toMutableList() ?: mutableListOf()
existing.add(localPage)
pageCache.cachePagesForSpace(spaceId, existing)
return localPage
}
val page = api.createPage(title, spaceId)
pageCache.cachePage(page)
val existing = pageCache.getCachedPagesForSpace(spaceId)?.toMutableList() ?: mutableListOf()
existing.add(page)
pageCache.cachePagesForSpace(spaceId, existing)
return page
}
suspend fun updatePage(pageId: String, content: String?, title: String?, icon: String? = null, isJson: Boolean = false): Page {
if (isOffline) {
syncManager.enqueueUpdatePage(pageId, content ?: "", title ?: "", icon)
val cached = pageCache.getCachedPage(pageId)
if (cached != null) {
val updated = cached.copy(
title = title ?: cached.title,
icon = icon ?: cached.icon,
content = content ?: cached.content
)
pageCache.cachePage(updated)
return updated
}
throw OfflineNotAvailableException("Página no encontrada en caché")
}
val page = api.updatePage(pageId, content, title, icon, isJson)
pageCache.cachePage(page)
return page
}
suspend fun deletePage(pageId: String, spaceId: String? = null) {
if (isOffline) {
syncManager.enqueueDeletePage(pageId)
pageCache.removeFromCache(pageId)
if (spaceId != null) {
val cached = pageCache.getCachedPagesForSpace(spaceId)?.filter { it.id != pageId }
if (cached != null) pageCache.cachePagesForSpace(spaceId, cached)
}
return
}
api.deletePage(pageId)
pageCache.removeFromCache(pageId)
if (spaceId != null) {
val cached = pageCache.getCachedPagesForSpace(spaceId)?.filter { it.id != pageId }
if (cached != null) pageCache.cachePagesForSpace(spaceId, cached)
}
}
suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page {
if (isOffline) throw OfflineNotAvailableException("Duplicar no disponible sin conexión")
val page = api.duplicatePage(pageId, targetSpaceId)
pageCache.cachePage(page)
return page
}
suspend fun movePageToSpace(pageId: String, spaceId: String) {
if (isOffline) {
syncManager.enqueueUpdatePage(pageId, "", "", null)
return
}
api.movePageToSpace(pageId, spaceId)
}
suspend fun movePageToPage(pageId: String, parentPageId: String?, position: String) {
if (isOffline) {
syncManager.enqueueUpdatePage(pageId, "", "", null)
return
}
api.movePageToPage(pageId, parentPageId, position)
}
// ── Recent ──
suspend fun getRecentPages(): List<Page> {
if (isOffline) throw OfflineNotAvailableException("Recientes no disponible sin conexión")
return api.getRecentPages()
}
// ── Shares ──
suspend fun getShares(): List<Share> {
if (isOffline) throw OfflineNotAvailableException("Compartir no disponible sin conexión")
return api.getShares()
}
suspend fun createShare(pageId: String): Share {
if (isOffline) throw OfflineNotAvailableException("Crear enlaces no disponible sin conexión")
return api.createShare(pageId)
}
suspend fun deleteShare(shareId: String) {
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
api.deleteShare(shareId)
}
// ── Comments ──
suspend fun getComments(pageId: String): List<Comment> {
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
return api.getComments(pageId)
}
suspend fun createComment(pageId: String, content: String): Comment {
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
return api.createComment(pageId, content)
}
suspend fun updateComment(commentId: String, content: String): Comment {
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
return api.updateComment(commentId, content)
}
suspend fun deleteComment(commentId: String) {
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
api.deleteComment(commentId)
}
// ── History ──
suspend fun getPageHistory(pageId: String): List<PageHistory> {
if (isOffline) throw OfflineNotAvailableException("Historial no disponible sin conexión")
return api.getPageHistory(pageId)
}
suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory {
if (isOffline) throw OfflineNotAvailableException("Historial no disponible sin conexión")
return api.getPageHistoryInfo(pageId, historyId)
}
// ── User ──
suspend fun getCurrentUser(): User {
if (isOffline) throw OfflineNotAvailableException("Usuario no disponible sin conexión")
return api.getCurrentUser()
}
suspend fun updateUser(name: String?): User {
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
return api.updateUser(name)
}
// ── Search ──
suspend fun searchPages(query: String): List<SearchResult> {
if (isOffline) throw OfflineNotAvailableException("Búsqueda no disponible sin conexión")
return api.searchPages(query)
}
// ── Attachments ──
suspend fun uploadAttachment(pageId: String, fileUri: Uri, context: Context): Attachment {
if (isOffline) {
val fileName = getFileNameFromUri(context, fileUri) ?: "file"
val mimeType = context.contentResolver.getType(fileUri) ?: "application/octet-stream"
val pendingDir = File(context.cacheDir, "attachments_pending/$pageId")
pendingDir.mkdirs()
val localFile = File(pendingDir, fileName)
try {
context.contentResolver.openInputStream(fileUri)?.use { input ->
localFile.outputStream().use { output ->
input.copyTo(output)
}
}
} catch (e: Exception) {
throw IOException("No se pudo guardar el archivo localmente: ${e.message}")
}
syncManager.enqueueUploadAttachment(pageId, localFile.absolutePath, fileName, mimeType)
return Attachment(
id = "pending_${System.currentTimeMillis()}",
fileName = fileName,
fileSize = localFile.length(),
mimeType = mimeType,
url = localFile.toURI().toString(),
pageId = pageId
)
}
return api.uploadAttachment(pageId, fileUri, context)
}
suspend fun getPageAttachments(pageId: String): List<Attachment> {
if (isOffline) throw OfflineNotAvailableException("Archivos adjuntos no disponible sin conexión")
return api.getPageAttachments(pageId)
}
suspend fun getPageContent(pageId: String): PageContent {
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
return api.getPageContent(pageId)
}
// ── Trash ──
suspend fun getDeletedPages(spaceId: String): List<DeletedPage> {
if (isOffline) throw OfflineNotAvailableException("Papelera no disponible sin conexión")
return api.getDeletedPages(spaceId)
}
suspend fun restorePage(pageId: String, spaceId: String? = null) {
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
api.restorePage(pageId, spaceId)
}
// ── Helpers ──
suspend fun getPageHtml(pageId: String): String {
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
return api.getPageHtml(pageId)
}
fun getAttachmentUrl(attachmentType: String, fileName: String): String {
return api.getAttachmentUrl(attachmentType, fileName)
}
private fun getFileNameFromUri(context: Context, uri: Uri): String? {
var fileName: String? = null
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val displayNameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (displayNameIndex >= 0) {
fileName = it.getString(displayNameIndex)
}
}
}
return fileName ?: uri.lastPathSegment
}
}

View File

@@ -0,0 +1,488 @@
package com.docmost.app.api
import android.util.Log
import com.docmost.app.BuildConfig
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.content.Context
import android.net.Uri
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
class DocmostApi(private val baseUrl: String) {
private val gson = Gson()
private val jsonMediaType = "application/json".toMediaType()
private var authToken: String? = null
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
fun setAuthToken(token: String?) {
authToken = token
}
private fun buildUrl(path: String): String {
val url = baseUrl.trimEnd('/')
return if (path.startsWith("/")) "$url$path" else "$url/$path"
}
private fun post(path: String, body: Any? = null): String {
val url = buildUrl(path)
val jsonBody = if (body != null) gson.toJson(body) else "{}"
val requestBody = jsonBody.toRequestBody(jsonMediaType)
if (BuildConfig.DEBUG) Log.d("DocmostAPI", "POST $url")
val builder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Content-Type", "application/json")
authToken?.let {
builder.addHeader("Cookie", "authToken=$it")
}
val request = builder.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (BuildConfig.DEBUG) Log.d("DocmostAPI", "HTTP ${response.code}")
if (!response.isSuccessful) {
val message = try {
val err = gson.fromJson(responseBody, ApiError::class.java)
err.message
} catch (e: Exception) {
"HTTP ${response.code}"
}
throw IOException(message)
}
return responseBody
}
suspend fun login(email: String, password: String): String = withContext(Dispatchers.IO) {
val url = buildUrl("/api/auth/login")
val jsonBody = gson.toJson(LoginRequest(email, password))
val requestBody = jsonBody.toRequestBody(jsonMediaType)
if (BuildConfig.DEBUG) Log.d("DocmostAPI", "POST $url")
val request = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Content-Type", "application/json")
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (BuildConfig.DEBUG) Log.d("DocmostAPI", "HTTP ${response.code}")
if (!response.isSuccessful) {
val message = try {
val err = gson.fromJson(responseBody, ApiError::class.java)
err.message
} catch (e: Exception) {
"Login failed (HTTP ${response.code})"
}
throw IOException(message)
}
val cookies = response.headers("Set-Cookie")
var token: String? = null
for (cookie in cookies) {
if (cookie.startsWith("authToken=", ignoreCase = true)) {
val parts = cookie.split(";")[0].trim()
token = parts.removePrefix("authToken=")
break
}
}
if (token.isNullOrBlank()) {
throw IOException("No auth token received")
}
authToken = token
token
}
suspend fun getSpaces(): List<Space> = withContext(Dispatchers.IO) {
val json = post("/api/spaces")
val type = object : TypeToken<DocmostResponse<SpacesData>>() {}.type
val response = gson.fromJson<DocmostResponse<SpacesData>>(json, type)
val items = response.data?.items ?: emptyList()
items
}
suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> = withContext(Dispatchers.IO) {
val json = post("/api/pages/sidebar-pages", SidebarPagesRequest(spaceId = spaceId, pageId = pageId))
val type = object : TypeToken<DocmostResponse<PagesData>>() {}.type
val response = gson.fromJson<DocmostResponse<PagesData>>(json, type)
response.data?.items ?: emptyList()
}
private suspend fun fetchPageChildren(spaceId: String, parentPage: Page, allPages: MutableList<Page>) {
if (parentPage.hasChildren) {
val children = getSidebarPages(spaceId, parentPage.id)
allPages.addAll(children)
children.forEach { child ->
fetchPageChildren(spaceId, child, allPages)
}
}
}
suspend fun getAllPagesInSpace(spaceId: String): List<Page> = withContext(Dispatchers.IO) {
val allPages = mutableListOf<Page>()
val rootPages = getSidebarPages(spaceId)
allPages.addAll(rootPages)
rootPages.forEach { page ->
fetchPageChildren(spaceId, page, allPages)
}
allPages
}
suspend fun getPageInfo(pageId: String, format: String = "json"): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/info", PageInfoRequest(pageId, format))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type
val response = gson.fromJson<DocmostResponse<Page>>(json, type)
response.data ?: throw IOException("Page not found in response")
}
suspend fun getPageHtml(pageId: String): String = withContext(Dispatchers.IO) {
val page = getPageInfo(pageId, "html")
val content = page.content as? String ?: throw IOException("Page content is not a string")
content
}
fun getAttachmentUrl(attachmentType: String, fileName: String): String {
return buildUrl("/api/attachments/img/$attachmentType/$fileName")
}
suspend fun updateSpace(spaceId: String, name: String?, description: String?): Space =
withContext(Dispatchers.IO) {
val json = post(
"/api/spaces/update",
SpaceUpdateRequest(
spaceId = spaceId,
name = name,
description = description
)
)
val type = object : TypeToken<DocmostResponse<Space>>() {}.type
val response = gson.fromJson<DocmostResponse<Space>>(json, type)
response.data ?: throw IOException("Space update response missing data")
}
suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String =
withContext(Dispatchers.IO) {
val url = buildUrl("/api/attachments/upload-image")
val inputStream = context.contentResolver.openInputStream(imageUri)
?: throw IOException("Cannot open image")
val bytes = inputStream.readBytes()
inputStream.close()
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("type", AttachmentType.SPACE_ICON)
.addFormDataPart("spaceId", spaceId)
.addFormDataPart("file", "icon.jpg", bytes.toRequestBody("image/*".toMediaType()))
.build()
val builder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Cookie", "authToken=$authToken")
val request = builder.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (!response.isSuccessful) {
throw IOException("Icon upload failed: $responseBody")
}
val attachment = gson.fromJson(responseBody, AttachmentUploadResponse::class.java)
attachment.fileName
}
suspend fun removeSpaceIcon(spaceId: String) = withContext(Dispatchers.IO) {
post("/api/attachments/remove-icon", RemoveIconRequest(AttachmentType.SPACE_ICON, spaceId))
}
suspend fun updatePage(pageId: String, content: String?, title: String?, icon: String? = null, isJson: Boolean = false): Page =
withContext(Dispatchers.IO) {
val json = post(
"/api/pages/update",
PageUpdateRequest(
pageId = pageId,
content = content,
title = title,
icon = icon,
format = if (isJson) "json" else "markdown",
operation = "replace"
)
)
val type = object : TypeToken<DocmostResponse<Page>>() {}.type
val response = gson.fromJson<DocmostResponse<Page>>(json, type)
response.data ?: throw IOException("Update response missing data")
}
suspend fun getCurrentUser(): User = withContext(Dispatchers.IO) {
val json = post("/api/users/me")
val type = object : TypeToken<DocmostResponse<UserInfoResponse>>() {}.type
val response = gson.fromJson<DocmostResponse<UserInfoResponse>>(json, type)
response.data?.user ?: throw IOException("User not found in response")
}
suspend fun updateUser(name: String?): User = withContext(Dispatchers.IO) {
val json = post("/api/users/update", UserUpdateRequest(name = name))
val type = object : TypeToken<DocmostResponse<User>>() {}.type
val response = gson.fromJson<DocmostResponse<User>>(json, type)
response.data ?: throw IOException("Update user response missing data")
}
suspend fun searchPages(query: String): List<SearchResult> = withContext(Dispatchers.IO) {
val json = post("/api/search", SearchRequest(query = query))
val type = object : TypeToken<DocmostResponse<SearchResultsResponse>>() {}.type
val response = gson.fromJson<DocmostResponse<SearchResultsResponse>>(json, type)
response.data?.items ?: emptyList()
}
suspend fun createSpace(name: String, slug: String, description: String? = null): Space =
withContext(Dispatchers.IO) {
val json = post("/api/spaces/create", CreateSpaceRequest(name = name, slug = slug, description = description))
val type = object : TypeToken<DocmostResponse<Space>>() {}.type
val response = gson.fromJson<DocmostResponse<Space>>(json, type)
response.data ?: throw IOException("Create space response missing data")
}
suspend fun removeAvatar() = withContext(Dispatchers.IO) {
post("/api/attachments/remove-icon", RemoveIconRequest(AttachmentType.AVATAR))
}
suspend fun uploadAvatar(imageUri: Uri, context: Context): String =
withContext(Dispatchers.IO) {
val url = buildUrl("/api/attachments/upload-image")
val inputStream = context.contentResolver.openInputStream(imageUri)
?: throw IOException("Cannot open image")
val bytes = inputStream.readBytes()
inputStream.close()
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("type", AttachmentType.AVATAR)
.addFormDataPart("file", "avatar.jpg", bytes.toRequestBody("image/*".toMediaType()))
.build()
val builder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Cookie", "authToken=$authToken")
val request = builder.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (!response.isSuccessful) {
throw IOException("Avatar upload failed: $responseBody")
}
val attachment = gson.fromJson(responseBody, AttachmentUploadResponse::class.java)
attachment.fileName
}
suspend fun getRecentPages(): List<Page> = withContext(Dispatchers.IO) {
val json = post("/api/pages/recent", RecentPageRequest())
val type = object : TypeToken<DocmostResponse<RecentPagesResponse>>() {}.type
val response = gson.fromJson<DocmostResponse<RecentPagesResponse>>(json, type)
response.data?.items ?: emptyList()
}
suspend fun createPage(title: String, spaceId: String): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/create", CreatePageRequest(title = title, spaceId = spaceId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type
val response = gson.fromJson<DocmostResponse<Page>>(json, type)
response.data ?: throw IOException("Create page response missing data")
}
suspend fun deletePage(pageId: String) = withContext(Dispatchers.IO) {
post("/api/pages/delete", DeletePageRequest(pageId = pageId))
}
suspend fun deleteSpace(spaceId: String) = withContext(Dispatchers.IO) {
post("/api/spaces/delete", SpaceIdDto(spaceId = spaceId))
}
suspend fun getShares(): List<Share> = withContext(Dispatchers.IO) {
val json = post("/api/shares/", mapOf("limit" to 50))
val type = object : TypeToken<DocmostResponse<SharesData>>() {}.type
val response = gson.fromJson<DocmostResponse<SharesData>>(json, type)
response.data?.items ?: emptyList()
}
suspend fun createShare(pageId: String): Share = withContext(Dispatchers.IO) {
val json = post("/api/shares/create", CreateShareDto(pageId = pageId))
val type = object : TypeToken<DocmostResponse<Share>>() {}.type
val response = gson.fromJson<DocmostResponse<Share>>(json, type)
response.data ?: throw IOException("Create share response missing data")
}
suspend fun deleteShare(shareId: String) = withContext(Dispatchers.IO) {
post("/api/shares/delete", ShareIdDto(shareId = shareId))
}
suspend fun getComments(pageId: String): List<Comment> = withContext(Dispatchers.IO) {
val json = post("/api/comments/", mapOf("pageId" to pageId))
val type = object : TypeToken<DocmostResponse<CommentsData>>() {}.type
val response = gson.fromJson<DocmostResponse<CommentsData>>(json, type)
response.data?.items ?: emptyList()
}
suspend fun createComment(pageId: String, content: String): Comment = withContext(Dispatchers.IO) {
val json = post("/api/comments/create", CreateCommentDto(pageId = pageId, content = content))
val type = object : TypeToken<DocmostResponse<Comment>>() {}.type
val response = gson.fromJson<DocmostResponse<Comment>>(json, type)
response.data ?: throw IOException("Create comment response missing data")
}
suspend fun updateComment(commentId: String, content: String): Comment = withContext(Dispatchers.IO) {
val json = post("/api/comments/update", UpdateCommentDto(commentId = commentId, content = content))
val type = object : TypeToken<DocmostResponse<Comment>>() {}.type
val response = gson.fromJson<DocmostResponse<Comment>>(json, type)
response.data ?: throw IOException("Update comment response missing data")
}
suspend fun deleteComment(commentId: String) = withContext(Dispatchers.IO) {
post("/api/comments/delete", CommentIdDto(commentId = commentId))
}
suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/duplicate", DuplicatePageRequest(pageId = pageId, spaceId = targetSpaceId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type
val response = gson.fromJson<DocmostResponse<Page>>(json, type)
response.data ?: throw IOException("Duplicate response missing data")
}
suspend fun movePageToSpace(pageId: String, spaceId: String) = withContext(Dispatchers.IO) {
post("/api/pages/move-to-space", MovePageToSpaceDto(pageId = pageId, spaceId = spaceId))
}
suspend fun movePageToPage(pageId: String, parentPageId: String?, position: String) = withContext(Dispatchers.IO) {
val body: Map<String, Any?> = mapOf(
"pageId" to pageId,
"parentPageId" to parentPageId,
"position" to position
)
post("/api/pages/move", body)
}
suspend fun getPageHistory(pageId: String): List<PageHistory> = withContext(Dispatchers.IO) {
val json = post("/api/pages/history", PageHistoryRequest(pageId = pageId))
val type = object : TypeToken<DocmostResponse<PageHistoryResponse>>() {}.type
val response = gson.fromJson<DocmostResponse<PageHistoryResponse>>(json, type)
response.data?.items ?: emptyList()
}
suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory = withContext(Dispatchers.IO) {
val json = post("/api/pages/history/info", mapOf("pageId" to pageId, "historyId" to historyId))
val type = object : TypeToken<DocmostResponse<PageHistory>>() {}.type
val response = gson.fromJson<DocmostResponse<PageHistory>>(json, type)
response.data ?: throw IOException("Page history not found in response")
}
suspend fun getPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) {
val json = post("/api/pages/info", PageInfoRequest(pageId = pageId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type
val response = gson.fromJson<DocmostResponse<Page>>(json, type)
val page = response.data ?: throw IOException("Page not found in response")
PageContent(source = page.source ?: "")
}
suspend fun getDeletedPages(spaceId: String): List<DeletedPage> = withContext(Dispatchers.IO) {
val json = post("/api/pages/trash", DeletedPagesRequest(spaceId = spaceId))
val type = object : TypeToken<DocmostResponse<DeletedPagesResponse>>() {}.type
val response = gson.fromJson<DocmostResponse<DeletedPagesResponse>>(json, type)
response.data?.items ?: emptyList()
}
suspend fun restorePage(pageId: String, spaceId: String? = null) = withContext(Dispatchers.IO) {
post("/api/pages/restore", RestorePageRequest(pageId = pageId, spaceId = spaceId))
}
suspend fun uploadAttachment(pageId: String, fileUri: android.net.Uri, context: android.content.Context): Attachment =
withContext(Dispatchers.IO) {
val url = buildUrl("/api/attachments/upload")
val inputStream = context.contentResolver.openInputStream(fileUri)
?: throw IOException("Cannot open file")
val bytes = inputStream.readBytes()
inputStream.close()
val fileName = getFileNameFromUri(context, fileUri) ?: "file"
val mimeType = context.contentResolver.getType(fileUri) ?: "application/octet-stream"
val requestBody = okhttp3.MultipartBody.Builder()
.setType(okhttp3.MultipartBody.FORM)
.addFormDataPart("type", AttachmentType.FILE)
.addFormDataPart("pageId", pageId)
.addFormDataPart("file", fileName, bytes.toRequestBody(mimeType.toMediaTypeOrNull()))
.build()
val builder = okhttp3.Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Cookie", "authToken=$authToken")
val request = builder.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (!response.isSuccessful) {
throw IOException("Attachment upload failed: $responseBody")
}
val attachment = gson.fromJson(responseBody, AttachmentUploadResponse::class.java)
Attachment(
id = attachment.id,
fileName = attachment.fileName,
fileSize = attachment.fileSize,
mimeType = attachment.mimeType,
url = getAttachmentUrl(AttachmentType.FILE, attachment.fileName),
pageId = pageId
)
}
suspend fun getPageAttachments(pageId: String): List<Attachment> = withContext(Dispatchers.IO) {
val json = post("/api/attachments/page", mapOf("pageId" to pageId))
val type = object : TypeToken<DocmostResponse<AttachmentsResponse>>() {}.type
val response = gson.fromJson<DocmostResponse<AttachmentsResponse>>(json, type)
response.data?.items ?: emptyList()
}
private fun getFileNameFromUri(context: android.content.Context, uri: android.net.Uri): String? {
var fileName: String? = null
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val displayNameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (displayNameIndex >= 0) {
fileName = it.getString(displayNameIndex)
}
}
}
return fileName ?: uri.lastPathSegment
}
}

View File

@@ -0,0 +1,366 @@
package com.docmost.app.api
import java.io.IOException
data class LoginRequest(
val email: String,
val password: String
)
data class Space(
val id: String = "",
val name: String = "",
val description: String? = null,
val logo: String? = null,
val slug: String? = null,
val creatorId: String? = null,
val isPersonal: Boolean = false,
val memberCount: Int? = null,
val membership: SpaceMembership? = null
)
data class SpaceMembership(
val userId: String = "",
val role: String = ""
)
data class User(
val id: String = "",
val email: String = "",
val name: String? = null,
val avatarUrl: String? = null,
val role: String? = null
)
data class UserInfo(
val id: String = "",
val name: String? = null,
val avatarUrl: String? = null
)
data class UserUpdateRequest(
val name: String? = null
)
data class UserInfoResponse(
val user: User? = null
)
object AttachmentType {
const val AVATAR = "avatar"
const val WORKSPACE_ICON = "workspace-icon"
const val SPACE_ICON = "space-icon"
const val PAGE_IMAGE = "page-image"
const val FILE = "file"
}
data class PaginationMeta(
val limit: Int = 0,
val hasNextPage: Boolean = false,
val hasPrevPage: Boolean = false,
val nextCursor: String? = null,
val prevCursor: String? = null
)
data class SpacesData(
val items: List<Space> = emptyList(),
val meta: PaginationMeta? = null
)
data class PagePermissions(
val canEdit: Boolean = true,
val hasRestriction: Boolean = false
)
data class Page(
val id: String = "",
val title: String = "",
val content: Any? = null,
val spaceId: String? = null,
val parentPageId: String? = null,
val icon: String? = null,
val slugId: String? = null,
val hasChildren: Boolean = false,
val position: String? = null,
val source: String? = null,
val creator: UserInfo? = null,
val lastUpdatedBy: UserInfo? = null,
val createdAt: String = "",
val updatedAt: String = "",
val labels: List<String>? = null,
val permissions: PagePermissions? = null
)
data class PagesData(
val items: List<Page> = emptyList(),
val meta: PaginationMeta? = null
)
data class PageInfoRequest(
val pageId: String,
val format: String = "json"
)
data class PageUpdateRequest(
val pageId: String,
val content: Any? = null,
val title: String? = null,
val icon: String? = null,
val parentPageId: String? = null,
val format: String = "markdown",
val operation: String = "replace"
)
data class SidebarPagesRequest(
val spaceId: String,
val pageId: String? = null
)
data class ApiError(
val message: String = "Unknown error"
)
data class SpaceUpdateRequest(
val spaceId: String,
val name: String? = null,
val description: String? = null,
val slug: String? = null
)
data class RemoveIconRequest(
val type: String,
val spaceId: String? = null
)
data class AttachmentUploadResponse(
val id: String = "",
val fileName: String = "",
val fileSize: Long = 0,
val mimeType: String = ""
)
data class SearchRequest(
val query: String,
val limit: Int = 25,
val offset: Int = 0
)
data class SearchResult(
val id: String = "",
val title: String = "",
val icon: String? = null,
val highlight: String? = null,
val spaceId: String? = null,
val spaceName: String? = null
)
data class SearchResultsResponse(
val items: List<SearchResult> = emptyList()
)
data class CreateSpaceRequest(
val name: String,
val slug: String,
val description: String? = null
)
data class CreatePageRequest(
val title: String,
val spaceId: String,
val icon: String? = null,
val content: String? = null,
val format: String? = "markdown",
val parentPageId: String? = null
)
data class DeletePageRequest(
val pageId: String
)
data class SpaceIdDto(
val spaceId: String
)
data class RecentPageRequest(
val limit: Int = 50,
val offset: Int = 0
)
data class RecentPagesResponse(
val items: List<Page> = emptyList()
)
data class DocmostResponse<T>(
val data: T? = null,
val success: Boolean = false,
val status: Int = 0
)
data class DuplicatePageRequest(
val pageId: String,
val spaceId: String? = null
)
data class MovePageToSpaceDto(
val pageId: String,
val spaceId: String
)
data class Share(
val id: String = "",
val pageId: String = "",
val key: String = "",
val spaceId: String = "",
val includeSubPages: Boolean = false,
val searchIndexing: Boolean = false,
val createdAt: String = "",
val page: SharePageInfo? = null
)
data class SharePageInfo(
val id: String = "",
val title: String = "",
val slugId: String = "",
val icon: String? = null
)
data class SharesData(
val items: List<Share> = emptyList()
)
data class CreateShareDto(
val pageId: String,
val includeSubPages: Boolean = false,
val searchIndexing: Boolean = false
)
data class ShareIdDto(
val shareId: String
)
data class Comment(
val id: String = "",
val pageId: String = "",
val content: com.google.gson.JsonObject? = null,
val parentCommentId: String? = null,
val creatorId: String = "",
val createdAt: String = "",
val updatedAt: String = "",
val creator: CommentCreator? = null
)
data class CommentCreator(
val id: String = "",
val name: String? = null,
val avatarUrl: String? = null
)
data class CommentsData(
val items: List<Comment> = emptyList()
)
data class CreateCommentDto(
val pageId: String,
val content: String
)
data class UpdateCommentDto(
val commentId: String,
val content: String
)
data class CommentIdDto(
val commentId: String
)
data class PageHistory(
val id: String = "",
val pageId: String = "",
val version: Int = 0,
val createdAt: String = "",
val lastUpdatedBy: UserInfo? = null,
val contributorIds: List<String> = emptyList(),
val content: Any? = null
)
data class PageHistoryRequest(
val pageId: String,
val limit: Int = 20,
val cursor: String? = null
)
data class PageHistoryResponse(
val items: List<PageHistory> = emptyList(),
val hasNextPage: Boolean = false,
val nextCursor: String? = null
)
data class FavoriteCreateDto(
val type: String,
val pageId: String
)
data class FavoriteRemoveDto(
val type: String,
val pageId: String
)
data class FavoriteIdsDto(
val type: String
)
data class FavoriteIdsResponse(
val items: List<String> = emptyList()
)
data class PageContent(
val source: String
)
data class DeletedPage(
val id: String = "",
val title: String = "",
val icon: String? = null,
val spaceId: String? = null,
val deletedAt: String = "",
val deletedBy: UserInfo? = null
)
data class DeletedPagesResponse(
val items: List<DeletedPage> = emptyList()
)
data class DeletedPagesRequest(
val spaceId: String,
val limit: Int = 50,
val cursor: String? = null
)
data class RestorePageRequest(
val pageId: String,
val spaceId: String? = null
)
data class Attachment(
val id: String = "",
val fileName: String = "",
val fileSize: Long = 0,
val mimeType: String = "",
val url: String = "",
val pageId: String = "",
val uploadedBy: UserInfo? = null,
val createdAt: String = ""
)
data class AttachmentUploadRequest(
val pageId: String,
val type: String = "attachment"
)
data class AttachmentsResponse(
val items: List<Attachment> = emptyList()
)
const val MAX_ATTACHMENT_SIZE_MB = 30
class OfflineNotAvailableException(message: String) : IOException(message)

View File

@@ -0,0 +1,187 @@
package com.docmost.app.data
import android.content.Context
import com.docmost.app.api.Page
import com.docmost.app.BuildConfig
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
import java.security.MessageDigest
data class CachedPageMeta(
val pageId: String,
val title: String,
val icon: String? = null,
val spaceId: String? = null,
val isPending: Boolean = false,
val updatedAt: Long = System.currentTimeMillis()
)
class PageCacheManager(private val context: Context) {
private val gson = Gson()
private var currentUserId: String = "default"
private val cacheDir: File
get() {
val userDir = File(context.cacheDir, "pages_cache")
val userSpecificDir = File(userDir, currentUserId)
return userSpecificDir.also { it.mkdirs() }
}
private val indexFile: File
get() = File(cacheDir, "index.json")
private val draftsDir: File
get() {
val dir = File(cacheDir, "drafts")
return dir.also { it.mkdirs() }
}
private val spacePagesDir: File
get() {
val dir = File(cacheDir, "space_pages")
return dir.also { it.mkdirs() }
}
fun setCurrentUser(email: String, serverUrl: String) {
val uniqueId = "${email.lowercase()}@${serverUrl.hash()}"
currentUserId = uniqueId
}
private fun String.hash(): String {
return MessageDigest.getInstance("SHA-256")
.digest(this.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(16)
}
fun cachePage(page: Page) {
try {
val pageFile = File(cacheDir, "${page.id}.json")
pageFile.writeText(gson.toJson(page))
val meta = CachedPageMeta(
pageId = page.id,
title = page.title,
icon = page.icon,
spaceId = page.spaceId,
isPending = false,
updatedAt = System.currentTimeMillis()
)
val index = getIndex().toMutableList()
index.removeAll { it.pageId == page.id }
index.add(meta)
indexFile.writeText(gson.toJson(index))
} catch (e: Exception) {
if (BuildConfig.DEBUG) android.util.Log.e("PageCache", "Cache error: ${e.message}")
}
}
fun saveDraft(pageId: String, content: String, title: String, icon: String?) {
try {
val draftFile = File(draftsDir, "$pageId.json")
val draft = mapOf(
"pageId" to pageId,
"content" to content,
"title" to title,
"icon" to icon,
"updatedAt" to System.currentTimeMillis()
)
draftFile.writeText(gson.toJson(draft))
if (BuildConfig.DEBUG) android.util.Log.d("PageCache", "Draft saved for page: $pageId")
} catch (e: Exception) {
if (BuildConfig.DEBUG) android.util.Log.e("PageCache", "Draft save error: ${e.message}")
}
}
fun getDraft(pageId: String): Map<String, Any?>? {
return try {
val draftFile = File(draftsDir, "$pageId.json")
if (!draftFile.exists()) return null
@Suppress("UNCHECKED_CAST")
gson.fromJson(draftFile.readText(), Map::class.java) as? Map<String, Any?>
} catch (e: Exception) {
null
}
}
fun deleteDraft(pageId: String) {
try {
File(draftsDir, "$pageId.json").delete()
} catch (_: Exception) { }
}
fun getCachedPage(pageId: String): Page? {
return try {
val pageFile = File(cacheDir, "$pageId.json")
if (!pageFile.exists()) return null
gson.fromJson(pageFile.readText(), Page::class.java)
} catch (e: Exception) {
null
}
}
fun getCachedPages(): List<CachedPageMeta> {
return try {
if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedPageMeta>>() {}.type
gson.fromJson(indexFile.readText(), type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
fun removeFromCache(pageId: String) {
try {
File(cacheDir, "$pageId.json").delete()
deleteDraft(pageId)
val index = getIndex().toMutableList()
index.removeAll { it.pageId == pageId }
indexFile.writeText(gson.toJson(index))
} catch (_: Exception) { }
}
fun cachePagesForSpace(spaceId: String, pages: List<Page>) {
try {
val file = File(spacePagesDir, "$spaceId.json")
file.writeText(gson.toJson(pages))
} catch (e: Exception) {
if (BuildConfig.DEBUG) android.util.Log.e("PageCache", "Cache space pages error: ${e.message}")
}
}
fun getCachedPagesForSpace(spaceId: String): List<Page>? {
return try {
val file = File(spacePagesDir, "$spaceId.json")
if (!file.exists()) return null
val type = object : TypeToken<List<Page>>() {}.type
gson.fromJson(file.readText(), type)
} catch (e: Exception) {
null
}
}
fun clearAll() {
try {
cacheDir.deleteRecursively()
cacheDir.mkdirs()
} catch (_: Exception) { }
}
fun clearUserCache() {
try {
cacheDir.deleteRecursively()
} catch (_: Exception) { }
}
fun getCachedPageCount(): Int = getIndex().size
private fun getIndex(): List<CachedPageMeta> {
return try {
if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedPageMeta>>() {}.type
gson.fromJson(indexFile.readText(), type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
}

View File

@@ -0,0 +1,139 @@
package com.docmost.app.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.google.gson.Gson
data class Credentials(
val url: String,
val email: String,
val password: String,
val authToken: String = "",
val avatarUrl: String? = null
)
class SessionManager(private val context: Context) {
private val gson = Gson()
private val prefs: SharedPreferences by lazy {
createEncryptedPrefs(context, PREFS_NAME)
}
private val savedAccountsPrefs: SharedPreferences by lazy {
createEncryptedPrefs(context, SAVED_ACCOUNTS_PREFS)
}
private fun createEncryptedPrefs(context: Context, prefsName: String): SharedPreferences {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
return try {
EncryptedSharedPreferences.create(
prefsName,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
context.getSharedPreferences(prefsName, Context.MODE_PRIVATE).edit().clear().apply()
EncryptedSharedPreferences.create(
prefsName,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
fun saveCredentials(credentials: Credentials) {
try {
val json = gson.toJson(credentials)
prefs.edit().putString(KEY_CREDENTIALS, json).apply()
} catch (e: Exception) {
clear()
}
}
fun getCredentials(): Credentials? {
val json = try {
prefs.getString(KEY_CREDENTIALS, null)
} catch (e: Exception) {
clear()
null
} ?: return null
return try {
gson.fromJson(json, Credentials::class.java)
} catch (e: Exception) {
null
}
}
fun saveAuthToken(token: String) {
val current = getCredentials() ?: return
saveCredentials(current.copy(authToken = token))
}
fun clear() {
try {
prefs.edit().clear().apply()
} catch (_: Exception) {}
}
fun hasSession(): Boolean {
val creds = getCredentials() ?: return false
return creds.url.isNotBlank() && creds.email.isNotBlank() && creds.authToken.isNotBlank()
}
fun saveAccount(credentials: Credentials) {
try {
val accounts = getSavedAccounts().toMutableList()
accounts.removeAll { it.email == credentials.email && it.url == credentials.url }
accounts.add(credentials)
val json = gson.toJson(accounts)
savedAccountsPrefs.edit().putString(KEY_SAVED_ACCOUNTS, json).apply()
} catch (e: Exception) {
context.getSharedPreferences(SAVED_ACCOUNTS_PREFS, Context.MODE_PRIVATE).edit().clear().commit()
val json = gson.toJson(listOf(credentials))
savedAccountsPrefs.edit().putString(KEY_SAVED_ACCOUNTS, json).commit()
}
}
fun getSavedAccounts(): List<Credentials> {
val json = try {
savedAccountsPrefs.getString(KEY_SAVED_ACCOUNTS, null)
} catch (e: Exception) {
context.getSharedPreferences(SAVED_ACCOUNTS_PREFS, Context.MODE_PRIVATE).edit().clear().commit()
null
} ?: return emptyList()
return try {
val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, Credentials::class.java).type
gson.fromJson(json, type)
} catch (e: Exception) {
emptyList()
}
}
fun removeAccount(email: String, url: String) {
try {
val accounts = getSavedAccounts().toMutableList()
accounts.removeAll { it.email == email && it.url == url }
val json = gson.toJson(accounts)
savedAccountsPrefs.edit().putString(KEY_SAVED_ACCOUNTS, json).apply()
} catch (_: Exception) {}
}
fun hasAccount(email: String, url: String): Boolean {
return getSavedAccounts().any { it.email == email && it.url == url }
}
companion object {
private const val PREFS_NAME = "docmost_session"
private const val KEY_CREDENTIALS = "credentials"
private const val SAVED_ACCOUNTS_PREFS = "docmost_saved_accounts"
private const val KEY_SAVED_ACCOUNTS = "saved_accounts"
}
}

View File

@@ -0,0 +1,135 @@
package com.docmost.app.data
import android.content.Context
import com.docmost.app.api.Space
import com.docmost.app.BuildConfig
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
import java.security.MessageDigest
data class CachedSpaceMeta(
val spaceId: String,
val name: String,
val slug: String? = null,
val isPending: Boolean = false,
val updatedAt: Long = System.currentTimeMillis()
)
class SpaceCacheManager(private val context: Context) {
private val gson = Gson()
private var currentUserId: String = "default"
private val cacheDir: File
get() {
val userDir = File(context.cacheDir, "spaces_cache")
val userSpecificDir = File(userDir, currentUserId)
return userSpecificDir.also { it.mkdirs() }
}
private val indexFile: File
get() = File(cacheDir, "index.json")
private fun String.hash(): String {
return MessageDigest.getInstance("SHA-256")
.digest(this.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(16)
}
fun setCurrentUser(email: String, serverUrl: String) {
val uniqueId = "${email.lowercase()}@${serverUrl.hash()}"
currentUserId = uniqueId
}
fun cacheSpaces(spaces: List<Space>) {
try {
val index = mutableListOf<CachedSpaceMeta>()
for (space in spaces) {
val spaceFile = File(cacheDir, "${space.id}.json")
spaceFile.writeText(gson.toJson(space))
index.add(CachedSpaceMeta(
spaceId = space.id,
name = space.name,
slug = space.id,
isPending = false,
updatedAt = System.currentTimeMillis()
))
}
indexFile.writeText(gson.toJson(index))
} catch (e: Exception) {
if (BuildConfig.DEBUG) android.util.Log.e("SpaceCache", "Cache spaces error: ${e.message}")
}
}
fun cacheSpace(space: Space) {
try {
val spaceFile = File(cacheDir, "${space.id}.json")
spaceFile.writeText(gson.toJson(space))
val index = getIndex().toMutableList()
index.removeAll { it.spaceId == space.id }
index.add(CachedSpaceMeta(
spaceId = space.id,
name = space.name,
slug = space.id,
isPending = false,
updatedAt = System.currentTimeMillis()
))
indexFile.writeText(gson.toJson(index))
} catch (e: Exception) {
if (BuildConfig.DEBUG) android.util.Log.e("SpaceCache", "Cache space error: ${e.message}")
}
}
fun getCachedSpaces(): List<Space> {
return try {
val index = getIndex()
index.mapNotNull { meta ->
val spaceFile = File(cacheDir, "${meta.spaceId}.json")
if (!spaceFile.exists()) return@mapNotNull null
gson.fromJson(spaceFile.readText(), Space::class.java)
}
} catch (e: Exception) {
emptyList()
}
}
fun getCachedSpace(spaceId: String): Space? {
return try {
val spaceFile = File(cacheDir, "$spaceId.json")
if (!spaceFile.exists()) return null
gson.fromJson(spaceFile.readText(), Space::class.java)
} catch (e: Exception) {
null
}
}
fun removeCachedSpace(spaceId: String) {
try {
File(cacheDir, "$spaceId.json").delete()
val index = getIndex().toMutableList()
index.removeAll { it.spaceId == spaceId }
indexFile.writeText(gson.toJson(index))
} catch (_: Exception) { }
}
fun getIndex(): List<CachedSpaceMeta> {
return try {
if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedSpaceMeta>>() {}.type
gson.fromJson(indexFile.readText(), type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
fun clearAll() {
try {
cacheDir.deleteRecursively()
cacheDir.mkdirs()
} catch (_: Exception) { }
}
}

View File

@@ -0,0 +1,504 @@
package com.docmost.app.data
import android.content.Context
import android.util.Log
import com.docmost.app.BuildConfig
import androidx.work.*
import com.docmost.app.api.DocmostApi
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
data class SyncResult(
val success: Boolean,
val message: String,
val conflictsResolved: Int = 0,
val operationsProcessed: Int = 0
)
class SyncManager(
private val context: Context,
private val api: DocmostApi,
private val pageCacheManager: PageCacheManager,
private val spaceCacheManager: SpaceCacheManager
) {
private val db = SyncQueueDatabase.getDatabase(context)
private val dao = db.syncQueueDao()
private val gson = Gson()
private val _syncStatus = MutableStateFlow(SyncStatus.IDLE)
val syncStatus: Flow<SyncStatus> = _syncStatus.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private val syncMutex = Mutex()
init {
setupWorkManager()
}
private fun setupWorkManager() {
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync_queue_worker",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
suspend fun enqueueOperation(operation: PendingOperation) {
dao.insertOperation(operation)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Enqueued operation: ${operation.type} for ${operation.entityId}")
if (operation.type == OperationType.CREATE_PAGE || operation.type == OperationType.CREATE_SPACE) {
scheduleImmediateSync()
}
}
suspend fun enqueueCreateSpace(name: String, slug: String, description: String?, tempId: String) {
val payload = gson.toJson(mapOf(
"name" to name,
"slug" to slug,
"description" to description,
"tempId" to tempId
))
val operation = PendingOperation(
type = OperationType.CREATE_SPACE,
entityType = "space",
entityId = null,
payload = payload,
timestamp = System.currentTimeMillis(),
tempId = tempId
)
enqueueOperation(operation)
}
suspend fun enqueueUpdateSpace(spaceId: String, name: String?, description: String?) {
val payload = gson.toJson(mapOf(
"spaceId" to spaceId,
"name" to name,
"description" to description
))
val operation = PendingOperation(
type = OperationType.UPDATE_SPACE,
entityType = "space",
entityId = spaceId,
payload = payload,
timestamp = System.currentTimeMillis()
)
enqueueOperation(operation)
}
suspend fun enqueueCreatePage(title: String, spaceId: String, tempId: String, content: String? = null) {
val payload = gson.toJson(mapOf(
"title" to title,
"spaceId" to spaceId,
"content" to content,
"tempId" to tempId
))
val operation = PendingOperation(
type = OperationType.CREATE_PAGE,
entityType = "page",
entityId = null,
payload = payload,
timestamp = System.currentTimeMillis(),
tempId = tempId
)
enqueueOperation(operation)
}
suspend fun enqueueUpdatePage(pageId: String, content: String, title: String, icon: String?) {
// Reemplazar operaciones UPDATE_PAGE previas para la misma página
// para que solo se sincronice el contenido más reciente
val existing = dao.getOperationByEntity(pageId, OperationType.UPDATE_PAGE)
if (existing != null) {
dao.deleteOperation(existing)
}
val payload = gson.toJson(mapOf(
"pageId" to pageId,
"content" to content,
"title" to title,
"icon" to icon,
"updatedAt" to System.currentTimeMillis()
))
val operation = PendingOperation(
type = OperationType.UPDATE_PAGE,
entityType = "page",
entityId = pageId,
payload = payload,
timestamp = System.currentTimeMillis()
)
enqueueOperation(operation)
}
suspend fun enqueueDeletePage(pageId: String) {
val payload = gson.toJson(mapOf("pageId" to pageId))
val operation = PendingOperation(
type = OperationType.DELETE_PAGE,
entityType = "page",
entityId = pageId,
payload = payload,
timestamp = System.currentTimeMillis()
)
enqueueOperation(operation)
}
suspend fun enqueueDeleteSpace(spaceId: String) {
val payload = gson.toJson(mapOf("spaceId" to spaceId))
val operation = PendingOperation(
type = OperationType.DELETE_SPACE,
entityType = "space",
entityId = spaceId,
payload = payload,
timestamp = System.currentTimeMillis()
)
enqueueOperation(operation)
}
suspend fun enqueueUploadAttachment(pageId: String, localPath: String, fileName: String, mimeType: String) {
val payload = gson.toJson(mapOf(
"pageId" to pageId,
"localPath" to localPath,
"fileName" to fileName,
"mimeType" to mimeType
))
val operation = PendingOperation(
type = OperationType.UPLOAD_ATTACHMENT,
entityType = "attachment",
entityId = pageId,
payload = payload,
timestamp = System.currentTimeMillis()
)
enqueueOperation(operation)
}
suspend fun processQueue(): SyncResult {
syncMutex.withLock {
if (_syncStatus.value == SyncStatus.SYNCING) {
if (BuildConfig.DEBUG) Log.d("SyncManager", "Already syncing, skipping concurrent call")
return SyncResult(success = true, message = "Ya sincronizando", operationsProcessed = 0)
}
_syncStatus.value = SyncStatus.SYNCING
var conflictsResolved = 0
var operationsProcessed = 0
try {
val operations = dao.getAllOperationsList()
if (BuildConfig.DEBUG) Log.d("SyncManager", "Processing ${operations.size} pending operations")
for (operation in operations) {
try {
when (operation.type) {
OperationType.CREATE_SPACE -> handleCreateSpace(operation)
OperationType.UPDATE_SPACE -> handleUpdateSpace(operation)
OperationType.DELETE_SPACE -> handleDeleteSpace(operation)
OperationType.CREATE_PAGE -> handleCreatePage(operation)
OperationType.UPDATE_PAGE -> handleUpdatePage(operation)
OperationType.DELETE_PAGE -> handleDeletePage(operation)
OperationType.UPLOAD_ATTACHMENT -> handleUploadAttachment(operation)
else -> if (BuildConfig.DEBUG) Log.w("SyncManager", "Unknown operation type: ${operation.type}")
}
dao.deleteOperation(operation)
operationsProcessed++
if (BuildConfig.DEBUG) Log.d("SyncManager", "Completed operation: ${operation.type}")
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.e("SyncManager", "Error processing operation ${operation.type}: ${e.message}")
if (e.message?.contains("409") == true || e.message?.contains("conflict") == true) {
conflictsResolved++
dao.deleteOperation(operation)
} else if (operation.retryCount < 3) {
val updated = operation.copy(retryCount = operation.retryCount + 1)
dao.updateOperation(updated)
} else {
dao.deleteOperation(operation)
if (BuildConfig.DEBUG) Log.e("SyncManager", "Operation failed after 3 retries: ${operation.type}")
}
}
}
val result = SyncResult(
success = true,
message = "Sincronización completada: $operationsProcessed operaciones",
conflictsResolved = conflictsResolved,
operationsProcessed = operationsProcessed
)
_syncStatus.value = SyncStatus.IDLE
return result
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.e("SyncManager", "Sync failed: ${e.message}")
_syncStatus.value = SyncStatus.IDLE
return SyncResult(
success = false,
message = "Error de sincronización: ${e.message}",
conflictsResolved = conflictsResolved
)
}
}
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleCreateSpace(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val name = payload["name"] as String
var slug = payload["slug"] as String
val description = payload["description"] as? String
val tempId = payload["tempId"] as? String
var space: com.docmost.app.api.Space? = null
var lastError: Exception? = null
for (attempt in 0 until 3) {
try {
space = api.createSpace(name, slug, description)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Space created on server: ${space.id} (was temp: $tempId)")
break
} catch (e: Exception) {
lastError = e
if (e.message?.contains("slug exists", ignoreCase = true) == true) {
slug = "${slug}_${System.currentTimeMillis()}"
if (BuildConfig.DEBUG) Log.d("SyncManager", "Slug collision, retrying with: $slug")
} else {
throw e
}
}
}
if (space == null) throw lastError ?: Exception("Failed to create space")
// Replace tempId with real ID in queued operations that reference it
if (tempId != null) {
val pendingOps = dao.getAllOperationsList()
for (op in pendingOps) {
try {
val opPayload = gson.fromJson(op.payload, Map::class.java) as Map<String, Any?>
val updatedPayload: Map<String, Any?>? = when {
op.type == OperationType.UPDATE_SPACE && opPayload["spaceId"] == tempId -> {
opPayload + ("spaceId" to space.id)
}
op.type == OperationType.CREATE_PAGE && opPayload["spaceId"] == tempId -> {
opPayload + ("spaceId" to space.id)
}
else -> null
}
if (updatedPayload != null) {
val updatedOp = op.copy(payload = gson.toJson(updatedPayload))
dao.updateOperation(updatedOp)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Updated ${op.type} payload: temp $tempId${space.id}")
}
} catch (_: Exception) { }
}
// Update space cache: replace temp entry with real server data
spaceCacheManager.cacheSpaces(listOf(space))
}
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleUpdateSpace(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val spaceId = payload["spaceId"] as String
val name = payload["name"] as? String
val description = payload["description"] as? String
api.updateSpace(spaceId, name, description)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Space updated on server: $spaceId")
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleDeleteSpace(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val spaceId = payload["spaceId"] as String
api.deleteSpace(spaceId)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Space deleted on server: $spaceId")
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleCreatePage(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val title = payload["title"] as String
val spaceId = payload["spaceId"] as String
val content = payload["content"] as? String
val tempId = payload["tempId"] as? String
val page = api.createPage(title, spaceId)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Page created on server: ${page.id} (was temp: $tempId)")
if (tempId != null) {
val pendingOps = dao.getAllOperationsList()
for (op in pendingOps) {
try {
val opPayload = gson.fromJson(op.payload, Map::class.java) as Map<String, Any?>
if (op.type == OperationType.DELETE_PAGE && opPayload["pageId"] == tempId) {
val updatedPayload = opPayload + ("pageId" to page.id)
val updatedOp = op.copy(payload = gson.toJson(updatedPayload))
dao.updateOperation(updatedOp)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Updated DELETE_PAGE payload: temp $tempId${page.id}")
}
} catch (_: Exception) { }
}
pageCacheManager.cachePage(page)
}
if (content != null) {
try {
api.updatePage(page.id, content, title, null)
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.e("SyncManager", "Failed to set page content: ${e.message}")
}
}
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleUpdatePage(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val pageId = payload["pageId"] as String
val content = payload["content"] as String
val title = payload["title"] as String
val icon = payload["icon"] as? String
try {
val remotePage = api.getPageInfo(pageId)
api.updatePage(pageId, content, title, icon)
pageCacheManager.cachePage(remotePage.copy(
title = title,
icon = icon,
content = content
))
if (BuildConfig.DEBUG) Log.d("SyncManager", "Page updated on server: $pageId")
} catch (e: Exception) {
if (e.message?.contains("404") == true) {
api.updatePage(pageId, content, title, icon)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Page updated on server (page not found remotely): $pageId")
} else {
throw e
}
}
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleDeletePage(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val pageId = payload["pageId"] as String
api.deletePage(pageId)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Page deleted on server: $pageId")
}
@Suppress("UNCHECKED_CAST")
private suspend fun handleUploadAttachment(operation: PendingOperation) {
val payload = gson.fromJson(operation.payload, Map::class.java) as Map<String, Any?>
val pageId = payload["pageId"] as String
val localPath = payload["localPath"] as String
val fileName = payload["fileName"] as String
val mimeType = payload["mimeType"] as String
val fileUri = android.net.Uri.parse(localPath)
api.uploadAttachment(pageId, fileUri, context)
if (BuildConfig.DEBUG) Log.d("SyncManager", "Attachment uploaded for page: $pageId")
}
fun observePendingCount(): Flow<Int> {
return dao.observePendingCount()
}
suspend fun getPendingCount(): Int {
return dao.getPendingCount()
}
suspend fun clearAll() {
dao.deleteAll()
}
private fun scheduleImmediateSync() {
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
}
companion object {
@Volatile
private var INSTANCE: SyncManager? = null
fun getInstance(
context: Context,
api: DocmostApi,
pageCacheManager: PageCacheManager,
spaceCacheManager: SpaceCacheManager
): SyncManager {
return INSTANCE ?: synchronized(this) {
val instance = SyncManager(context.applicationContext, api, pageCacheManager, spaceCacheManager)
INSTANCE = instance
instance
}
}
}
}
enum class SyncStatus {
IDLE,
SYNCING,
ERROR
}
class SyncWorker(
private val context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val syncManager = SyncManager.getInstance(
context,
createApi(),
PageCacheManager(context),
SpaceCacheManager(context)
)
val result = syncManager.processQueue()
if (result.success) {
Result.success()
} else {
Result.retry()
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) Log.e("SyncWorker", "Sync failed: ${e.message}")
Result.retry()
}
}
private fun createApi(): DocmostApi {
val sessionManager = com.docmost.app.data.SessionManager(context)
val creds = sessionManager.getCredentials()
val api = DocmostApi(creds?.url ?: "")
api.setAuthToken(creds?.authToken)
return api
}
}

View File

@@ -0,0 +1,56 @@
package com.docmost.app.data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
enum class OperationType {
CREATE_SPACE,
UPDATE_SPACE,
DELETE_SPACE,
CREATE_PAGE,
UPDATE_PAGE,
DELETE_PAGE,
UPLOAD_IMAGE,
UPLOAD_ATTACHMENT,
UPDATE_COMMENT,
DELETE_COMMENT
}
@Entity(
tableName = "pending_operations",
indices = [
Index(value = ["entityId"]),
Index(value = ["entityType"]),
Index(value = ["type"])
]
)
data class PendingOperation(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val type: OperationType,
val entityType: String,
val entityId: String?,
val payload: String,
val timestamp: Long,
val retryCount: Int = 0,
val createdAt: Long = System.currentTimeMillis(),
val tempId: String? = null
)
class Converters {
private val gson = Gson()
@TypeConverter
fun fromOperationType(value: OperationType): String {
return value.name
}
@TypeConverter
fun toOperationType(value: String): OperationType {
return OperationType.valueOf(value)
}
}

View File

@@ -0,0 +1,49 @@
package com.docmost.app.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncQueueDao {
@Query("SELECT * FROM pending_operations ORDER BY timestamp ASC")
fun getAllOperations(): Flow<List<PendingOperation>>
@Query("SELECT * FROM pending_operations ORDER BY timestamp ASC")
suspend fun getAllOperationsList(): List<PendingOperation>
@Query("SELECT * FROM pending_operations WHERE entityId = :entityId AND type = :type")
suspend fun getOperationByEntity(entityId: String, type: OperationType): PendingOperation?
@Query("SELECT COUNT(*) FROM pending_operations")
suspend fun getPendingCount(): Int
@Query("SELECT COUNT(*) FROM pending_operations")
fun observePendingCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOperation(operation: PendingOperation): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOperations(operations: List<PendingOperation>)
@Update
suspend fun updateOperation(operation: PendingOperation)
@Delete
suspend fun deleteOperation(operation: PendingOperation)
@Query("DELETE FROM pending_operations WHERE id = :id")
suspend fun deleteOperationById(id: Long)
@Query("DELETE FROM pending_operations WHERE entityId = :entityId")
suspend fun deleteOperationsByEntity(entityId: String)
@Query("DELETE FROM pending_operations WHERE entityType = :entityType")
suspend fun deleteOperationsByEntityType(entityType: String)
@Query("DELETE FROM pending_operations")
suspend fun deleteAll()
@Query("DELETE FROM pending_operations WHERE retryCount >= 3")
suspend fun deleteFailedOperations()
}

View File

@@ -0,0 +1,36 @@
package com.docmost.app.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [PendingOperation::class],
version = 2,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class SyncQueueDatabase : RoomDatabase() {
abstract fun syncQueueDao(): SyncQueueDao
companion object {
@Volatile
private var INSTANCE: SyncQueueDatabase? = null
fun getDatabase(context: Context): SyncQueueDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
SyncQueueDatabase::class.java,
"sync_queue_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,80 @@
package com.docmost.app.ui
import android.graphics.*
import android.graphics.drawable.Drawable
class GradientBorderDrawable : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val clipPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
private var colorStart = Color.TRANSPARENT
private var colorEnd = Color.TRANSPARENT
private var strokeWidth = 0f
private var cornerRadius = 0f
private var gradientShader: Shader? = null
fun setStroke(strokeWidth: Float, cornerRadius: Float) {
this.strokeWidth = strokeWidth
this.cornerRadius = cornerRadius
invalidateSelf()
}
fun setGradientColors(start: Int, end: Int) {
this.colorStart = start
this.colorEnd = end
gradientShader = null
invalidateSelf()
}
override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
super.setBounds(left, top, right, bottom)
gradientShader = null
}
override fun draw(canvas: Canvas) {
val b = bounds
if (b.width() <= 0 || b.height() <= 0) return
if (gradientShader == null) {
gradientShader = LinearGradient(
0f, 0f, b.width().toFloat(), 0f,
colorStart, colorEnd, Shader.TileMode.CLAMP
)
}
paint.shader = gradientShader
paint.style = Paint.Style.FILL
val saved = canvas.saveLayer(b.left.toFloat(), b.top.toFloat(), b.right.toFloat(), b.bottom.toFloat(), null)
canvas.drawRoundRect(
0f, 0f, b.width().toFloat(), b.height().toFloat(),
cornerRadius, cornerRadius, paint
)
val inset = strokeWidth
canvas.drawRoundRect(
inset, inset,
b.width() - inset, b.height() - inset,
maxOf(0f, cornerRadius - inset), maxOf(0f, cornerRadius - inset),
clipPaint
)
canvas.restoreToCount(saved)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}

View File

@@ -0,0 +1,95 @@
package com.docmost.app.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.docmost.app.R
import com.docmost.app.api.DocmostApi
import com.docmost.app.data.Credentials
import com.docmost.app.data.SessionManager
import com.docmost.app.databinding.ActivityLoginBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var sessionManager: SessionManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.hide()
sessionManager = SessionManager(this)
if (sessionManager.hasSession()) {
navigateToSpaces()
return
}
val saved = sessionManager.getCredentials()
if (saved != null) {
binding.etUrl.setText(saved.url)
binding.etEmail.setText(saved.email)
binding.etPassword.setText(saved.password)
}
binding.btnConnect.setOnClickListener {
val url = binding.etUrl.text.toString().trim()
val email = binding.etEmail.text.toString().trim()
val password = binding.etPassword.text.toString()
if (url.isBlank() || email.isBlank() || password.isBlank()) {
Toast.makeText(this, R.string.fill_all_fields, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
doLogin(url, email, password)
}
}
private fun doLogin(url: String, email: String, password: String) {
binding.btnConnect.isEnabled = false
binding.btnConnect.text = getString(R.string.connecting)
CoroutineScope(Dispatchers.IO).launch {
try {
val api = DocmostApi(url)
val token = api.login(email, password)
val user = api.getCurrentUser()
val avatarUrl = user?.avatarUrl
val credentials = Credentials(url, email, password, token, avatarUrl)
sessionManager.saveCredentials(credentials)
sessionManager.saveAccount(credentials)
withContext(Dispatchers.Main) {
navigateToSpaces()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
binding.btnConnect.isEnabled = true
binding.btnConnect.text = getString(R.string.connect)
Toast.makeText(
this@LoginActivity,
"Error: ${e.message}",
Toast.LENGTH_LONG
).show()
}
}
}
}
private fun navigateToSpaces() {
val intent = Intent(this, SpacesActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,526 @@
package com.docmost.app.ui
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.docmost.app.R
import com.docmost.app.api.AttachmentType
import com.docmost.app.api.Page
import com.docmost.app.api.Space
import com.docmost.app.BuildConfig
import com.google.android.material.color.MaterialColors
class SpacesAdapter(
private val onSpaceToggle: (Space) -> Unit,
private val onPageClick: (Page) -> Unit,
private val onSpaceEdit: (Space) -> Unit,
private val onAddPage: (Space) -> Unit,
private val onPageDelete: (Page) -> Unit,
private val onPageLongClick: (Page) -> Unit,
private val onPageToggle: (Page) -> Unit,
private val baseUrl: String,
var currentUserId: String = ""
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_SPACE = 1
}
private sealed class DisplayItem {
data class Header(val label: String) : DisplayItem()
data class SpaceItem(val space: Space) : DisplayItem()
}
private val displayItems = mutableListOf<DisplayItem>()
private val spaces = mutableListOf<Space>()
private val pagesMap = mutableMapOf<String, List<Page>>()
private val expandedSpaces = mutableSetOf<String>()
private val childPagesMap = mutableMapOf<String, List<Page>>()
private val expandedPages = mutableSetOf<String>()
private var animateEntry = false
override fun getItemViewType(position: Int): Int {
return when (displayItems[position]) {
is DisplayItem.Header -> TYPE_HEADER
is DisplayItem.SpaceItem -> TYPE_SPACE
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_HEADER -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_space_header, parent, false)
HeaderViewHolder(view)
}
else -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_space, parent, false)
SpaceViewHolder(view)
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = displayItems[position]) {
is DisplayItem.Header -> (holder as HeaderViewHolder).bind(item.label)
is DisplayItem.SpaceItem -> (holder as SpaceViewHolder).bind(item.space)
}
}
override fun getItemCount(): Int = displayItems.size
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
super.onViewAttachedToWindow(holder)
if (!animateEntry) return
val pos = holder.absoluteAdapterPosition
if (pos < 0) return
val density = holder.itemView.resources.displayMetrics.density
holder.itemView.apply {
alpha = 0f
translationY = 40 * density
postDelayed({
animate()
.alpha(1f)
.translationY(0f)
.setDuration(400)
.setInterpolator(DecelerateInterpolator())
.start()
}, (pos * 60L).coerceAtMost(500L))
}
}
private fun buildDisplayItems() {
displayItems.clear()
val effectiveUserId = currentUserId.ifBlank {
spaces.firstNotNullOfOrNull { it.membership?.userId } ?: ""
}
if (BuildConfig.DEBUG) android.util.Log.d("SpacesAdapter", "buildDisplayItems: currentUserId='$currentUserId', effectiveUserId='$effectiveUserId', spaces count=${spaces.size}")
for (space in spaces) {
if (BuildConfig.DEBUG) android.util.Log.d("SpacesAdapter", " space='${space.name}' creatorId='${space.creatorId}' membership=${space.membership}")
}
val ownSpaces = spaces.filter {
it.creatorId == effectiveUserId
}
val sharedSpaces = spaces.filter {
effectiveUserId.isNotBlank() && it.creatorId != effectiveUserId
}
if (BuildConfig.DEBUG) android.util.Log.d("SpacesAdapter", "ownSpaces=${ownSpaces.size}, sharedSpaces=${sharedSpaces.size}")
if (ownSpaces.isNotEmpty()) {
displayItems.add(DisplayItem.Header("Tus espacios"))
for (space in ownSpaces) {
displayItems.add(DisplayItem.SpaceItem(space))
}
}
if (sharedSpaces.isNotEmpty()) {
displayItems.add(DisplayItem.Header("Compartidos contigo"))
for (space in sharedSpaces) {
displayItems.add(DisplayItem.SpaceItem(space))
}
}
}
fun submitList(
spaces: List<Space>,
pagesMap: Map<String, List<Page>>,
expandedSpaces: Set<String>,
childPagesMap: Map<String, List<Page>> = emptyMap(),
expandedPages: Set<String> = emptySet()
) {
val hadItems = this.spaces.isNotEmpty()
this.spaces.clear()
this.spaces.addAll(spaces)
this.pagesMap.clear()
this.pagesMap.putAll(pagesMap)
this.expandedSpaces.clear()
this.expandedSpaces.addAll(expandedSpaces)
this.childPagesMap.clear()
this.childPagesMap.putAll(childPagesMap)
this.expandedPages.clear()
this.expandedPages.addAll(expandedPages)
buildDisplayItems()
animateEntry = !hadItems && this.spaces.isNotEmpty()
notifyDataSetChanged()
if (animateEntry) {
recyclerViewRef?.post { animateEntry = false }
}
}
private var recyclerViewRef: View? = null
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
recyclerViewRef = recyclerView
}
inner class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvLabel: TextView = itemView.findViewById(R.id.tvHeaderLabel)
fun bind(label: String) {
tvLabel.text = label
}
}
inner class SpaceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvName: TextView = itemView.findViewById(R.id.tvSpaceName)
private val tvDesc: TextView = itemView.findViewById(R.id.tvSpaceDescription)
private val tvRole: TextView = itemView.findViewById(R.id.tvRole)
private val btnExpand: ImageButton = itemView.findViewById(R.id.btnExpand)
private val btnEdit: ImageButton = itemView.findViewById(R.id.btnEditSpace)
private val ivIcon: ImageView = itemView.findViewById(R.id.ivSpaceIcon)
private val ivBlurredBg: ImageView = itemView.findViewById(R.id.ivBlurredBg)
private val vScrim: View = itemView.findViewById(R.id.vScrim)
private val ivGradientBorder: View = itemView.findViewById(R.id.ivGradientBorder)
private val pagesContainer: LinearLayout = itemView.findViewById(R.id.pagesContainer)
private val tvPageCount: TextView = itemView.findViewById(R.id.tvPageCount)
private var currentSpace: Space? = null
private var wasExpanded = false
private var isCollapsing = false
private val cardView: com.google.android.material.card.MaterialCardView =
itemView as com.google.android.material.card.MaterialCardView
private val gradientBorderDrawable = GradientBorderDrawable()
private val density = itemView.resources.displayMetrics.density
init {
ivGradientBorder.background = gradientBorderDrawable
gradientBorderDrawable.setStroke(
2 * density,
12 * density
)
val defaultStart = MaterialColors.getColor(
itemView, com.google.android.material.R.attr.colorPrimaryContainer
)
val defaultEnd = MaterialColors.getColor(
itemView, com.google.android.material.R.attr.colorSecondaryContainer
)
gradientBorderDrawable.setGradientColors(defaultStart, defaultEnd)
}
private fun extractGradientColors(bitmap: Bitmap): Pair<Int, Int>? {
return try {
val software = if (bitmap.config == Bitmap.Config.HARDWARE) {
bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else {
bitmap
} ?: return null
val size = minOf(software.width, software.height, 50)
val scaled = Bitmap.createScaledBitmap(software, size, size, true)
val colors = mutableListOf<Int>()
val step = maxOf(1, size / 10)
for (x in 0 until size step step) {
for (y in 0 until size step step) {
colors.add(scaled.getPixel(x, y))
}
}
if (colors.size < 2) return null
colors.sortBy { color ->
0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)
}
Pair(colors.first(), colors.last())
} catch (e: Exception) {
if (BuildConfig.DEBUG) android.util.Log.e("SpacesAdapter", "extractGradientColors failed: ${e.message}")
null
}
}
private fun getRoleLabel(role: String): String {
return when (role.lowercase()) {
"admin" -> "Admin"
"writer" -> "Editor"
"reader" -> "Visor"
else -> role
}
}
fun bind(space: Space) {
currentSpace = space
tvName.text = space.name
if (space.description.isNullOrBlank()) {
tvDesc.visibility = View.GONE
} else {
tvDesc.visibility = View.VISIBLE
tvDesc.text = space.description
}
val isShared = currentUserId.isNotBlank() && space.creatorId != currentUserId
if (isShared && space.membership?.role.isNullOrBlank().not()) {
tvRole.visibility = View.VISIBLE
tvRole.text = getRoleLabel(space.membership!!.role)
} else {
tvRole.visibility = View.GONE
}
val isExpanded = expandedSpaces.contains(space.id)
btnExpand.setImageResource(
if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more
)
itemView.setOnClickListener { onSpaceToggle(space) }
btnExpand.setOnClickListener { onSpaceToggle(space) }
btnEdit.setOnClickListener { onSpaceEdit(space) }
val pages = pagesMap[space.id]
if (isExpanded || pages == null) {
tvPageCount.visibility = View.GONE
} else {
tvPageCount.visibility = View.VISIBLE
tvPageCount.text = "${pages.size} páginas"
}
if (isExpanded && !wasExpanded) {
pagesContainer.visibility = View.VISIBLE
pagesContainer.alpha = 1f
renderPages()
pagesContainer.post { animatePageEntries() }
} else if (!isExpanded && wasExpanded) {
animatePageExit()
} else if (isExpanded) {
pagesContainer.visibility = View.VISIBLE
pagesContainer.alpha = 1f
renderPages()
} else {
pagesContainer.visibility = View.GONE
pagesContainer.removeAllViews()
}
wasExpanded = isExpanded
loadSpaceIcon(space)
}
fun animateCollapse(onDone: () -> Unit) {
if (isCollapsing) return
isCollapsing = true
val density = itemView.resources.displayMetrics.density
val childCount = pagesContainer.childCount
if (childCount == 0) {
isCollapsing = false
onDone()
return
}
var remaining = childCount
for (i in 0 until childCount) {
pagesContainer.getChildAt(i).animate()
.alpha(0f)
.translationY(-20 * density)
.setDuration(200)
.setInterpolator(DecelerateInterpolator())
.withEndAction {
remaining--
if (remaining == 0) {
isCollapsing = false
onDone()
}
}
.start()
}
}
private fun animatePageEntries() {
val density = itemView.resources.displayMetrics.density
for (i in 0 until pagesContainer.childCount) {
val child = pagesContainer.getChildAt(i)
child.alpha = 0f
child.translationY = 30 * density
child.animate()
.alpha(1f)
.translationY(0f)
.setDuration(350)
.setStartDelay((i * 60L).coerceAtMost(400L))
.setInterpolator(DecelerateInterpolator())
.start()
}
}
private fun animatePageExit() {
val childCount = pagesContainer.childCount
if (childCount == 0) {
pagesContainer.visibility = View.GONE
pagesContainer.removeAllViews()
return
}
if (pagesContainer.getChildAt(0).alpha == 0f) {
pagesContainer.visibility = View.GONE
pagesContainer.removeAllViews()
return
}
var remaining = childCount
for (i in 0 until childCount) {
pagesContainer.getChildAt(i).animate()
.alpha(0f)
.setDuration(150)
.setInterpolator(DecelerateInterpolator())
.withEndAction {
remaining--
if (remaining == 0) {
pagesContainer.visibility = View.GONE
pagesContainer.removeAllViews()
}
}
.start()
}
}
private fun renderPages() {
pagesContainer.removeAllViews()
val space = currentSpace ?: return
val pages = pagesMap[space.id]
if (pages.isNullOrEmpty()) {
val addPageView = createAddPageView()
pagesContainer.addView(addPageView)
return
}
for (page in pages) {
renderPageWithChildren(page, 0)
}
val addPageView = createAddPageView()
pagesContainer.addView(addPageView)
}
private fun renderPageWithChildren(page: Page, depth: Int) {
val pageView = createPageView(page, depth)
pagesContainer.addView(pageView)
if (expandedPages.contains(page.id)) {
val children = childPagesMap[page.id]
if (!children.isNullOrEmpty()) {
for (child in children) {
renderPageWithChildren(child, depth + 1)
}
}
}
}
private fun createPageView(page: Page, depth: Int): View {
val view = LayoutInflater.from(itemView.context).inflate(R.layout.item_page, pagesContainer, false)
val tvTitle: TextView = view.findViewById(R.id.tvPageTitle)
val tvIcon: TextView = view.findViewById(R.id.tvPageIcon)
val ivIcon: ImageView = view.findViewById(R.id.ivPageIcon)
val btnDelete: ImageButton = view.findViewById(R.id.btnDeletePage)
val btnExpand: ImageButton = view.findViewById(R.id.btnExpandPage)
tvTitle.text = if (page.title.isNullOrBlank()) "Untitled" else page.title
if (page.icon.isNullOrBlank()) {
tvIcon.visibility = View.GONE
ivIcon.visibility = View.VISIBLE
} else {
tvIcon.visibility = View.VISIBLE
tvIcon.text = page.icon
ivIcon.visibility = View.GONE
}
val density = itemView.resources.displayMetrics.density
val basePadding = 24
val paddingStart = ((basePadding + depth * 24) * density).toInt()
view.setPadding(paddingStart, view.paddingTop, view.paddingRight, view.paddingBottom)
if (page.hasChildren) {
btnExpand.visibility = View.VISIBLE
val isExpanded = expandedPages.contains(page.id)
btnExpand.setImageResource(
if (isExpanded) R.drawable.ic_expand_less else R.drawable.ic_expand_more
)
btnExpand.setOnClickListener {
onPageToggle(page)
}
} else {
btnExpand.visibility = View.GONE
}
view.setOnClickListener { onPageClick(page) }
view.setOnLongClickListener { onPageLongClick(page); true }
btnDelete.visibility = View.VISIBLE
btnDelete.setOnClickListener { onPageDelete(page) }
return view
}
private fun createAddPageView(): View {
val view = LayoutInflater.from(itemView.context).inflate(R.layout.item_add_page, pagesContainer, false)
currentSpace?.let { space -> view.setOnClickListener { onAddPage(space) } }
return view
}
private fun loadSpaceIcon(space: Space) {
if (space.logo.isNullOrBlank()) {
ivIcon.visibility = View.GONE
ivIcon.setImageDrawable(null)
ivBlurredBg.visibility = View.GONE
vScrim.visibility = View.GONE
return
}
ivIcon.visibility = View.VISIBLE
val url = buildString {
append(baseUrl.trimEnd('/'))
append("/api/attachments/img/${AttachmentType.SPACE_ICON}/${space.logo}")
}
ivBlurredBg.visibility = View.GONE
vScrim.visibility = View.GONE
ivIcon.setImageResource(R.drawable.ic_page)
com.docmost.app.utils.ImageLoader.loadWithBitmap(url, ivIcon, onBitmapLoaded = { bitmap: Bitmap ->
extractGradientColors(bitmap)?.let { (c1, c2) ->
gradientBorderDrawable.setGradientColors(c1, c2)
}
setBlurredBackground(bitmap)
})
}
private fun setBlurredBackground(bitmap: Bitmap) {
val surfaceColor = MaterialColors.getColor(
itemView, com.google.android.material.R.attr.colorSurface
)
val scale = 6
val smallW = maxOf(bitmap.width / scale, 1)
val smallH = maxOf(bitmap.height / scale, 1)
val small = Bitmap.createScaledBitmap(bitmap, smallW, smallH, true)
val blurred = Bitmap.createScaledBitmap(small, smallW * 2, smallH * 2, true)
ivBlurredBg.setImageBitmap(blurred)
ivBlurredBg.alpha = 0.35f
ivBlurredBg.visibility = View.VISIBLE
vScrim.background = GradientDrawable(
GradientDrawable.Orientation.LEFT_RIGHT,
intArrayOf(Color.TRANSPARENT, Color.TRANSPARENT, surfaceColor, surfaceColor)
)
vScrim.visibility = View.VISIBLE
}
}
}

View File

@@ -0,0 +1,260 @@
package com.docmost.app.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.docmost.app.R
import com.docmost.app.api.DocmostApi
import com.docmost.app.data.Credentials
import com.docmost.app.data.SessionManager
import com.docmost.app.utils.ContentConverter
import com.google.android.material.button.MaterialButton
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class VersionViewerActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var progress: CircularProgressIndicator
private lateinit var actionBar: LinearLayout
private lateinit var ivAvatar: ShapeableImageView
private lateinit var tvAuthor: TextView
private lateinit var tvDateTime: TextView
private lateinit var btnLoadVersion: MaterialButton
private lateinit var api: DocmostApi
private var creds: Credentials? = null
private var currentPageContent: String = ""
private var originalTipTapJson: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_version_viewer)
// Hide the default ActionBar
supportActionBar?.hide()
webView = findViewById(R.id.webViewVersion)
progress = findViewById(R.id.progressVersion)
actionBar = findViewById(R.id.actionBar)
ivAvatar = findViewById(R.id.ivAvatar)
tvAuthor = findViewById(R.id.tvAuthor)
tvDateTime = findViewById(R.id.tvDateTime)
btnLoadVersion = findViewById(R.id.btnLoadVersion)
val pageId = intent.getStringExtra("pageId") ?: return finish()
val historyId = intent.getStringExtra("historyId") ?: return finish()
val author = intent.getStringExtra("author") ?: "Desconocido"
val dateTime = intent.getStringExtra("dateTime") ?: ""
val sessionManager = SessionManager(this)
com.docmost.app.utils.ImageLoader.init(this, sessionManager)
creds = sessionManager.getCredentials()
if (creds == null) {
finish()
return
}
api = DocmostApi(creds!!.url)
api.setAuthToken(creds!!.authToken)
tvAuthor.text = author
tvDateTime.text = dateTime
// Cargar avatar del autor
CoroutineScope(Dispatchers.IO).launch {
try {
val history = api.getPageHistoryInfo(pageId, historyId)
val avatarUrl = history.lastUpdatedBy?.avatarUrl
if (!avatarUrl.isNullOrBlank()) {
withContext(Dispatchers.Main) {
val fullAvatarUrl = "${creds?.url}/api/attachments/img/avatar/$avatarUrl"
com.docmost.app.utils.ImageLoader.loadSync(fullAvatarUrl, ivAvatar, R.drawable.ic_page)
}
} else {
withContext(Dispatchers.Main) {
ivAvatar.setImageResource(R.drawable.ic_page)
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
ivAvatar.setImageResource(R.drawable.ic_page)
}
}
}
btnLoadVersion.setOnClickListener {
loadVersionInEditor(pageId, historyId)
}
setupWebView(pageId, historyId)
}
private fun setupWebView(pageId: String, historyId: String) {
val isDark = when (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) {
android.content.res.Configuration.UI_MODE_NIGHT_YES -> true
else -> false
}
CoroutineScope(Dispatchers.IO).launch {
try {
val history = api.getPageHistoryInfo(pageId, historyId)
val htmlContent = if (history.content != null) {
val gson = Gson()
val json = gson.toJson(history.content)
originalTipTapJson = json
ContentConverter.tipTapToHtml(json)
} else {
"<p>Sin contenido</p>"
}
withContext(Dispatchers.Main) {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
builtInZoomControls = true
displayZoomControls = false
loadWithOverviewMode = true
useWideViewPort = true
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
progress.visibility = View.GONE
}
}
val darkModeCss = if (isDark) """
body {
background-color: #1a1a1a !important;
color: #e0e0e0 !important;
}
.version-banner {
background: #2d2d2d !important;
border-color: #404040 !important;
color: #e0e0e0 !important;
}
pre, code {
background: #2d2d2d !important;
color: #e0e0e0 !important;
}
blockquote {
border-left-color: #404040 !important;
color: #9ca3af !important;
}
th {
background: #2a2a2a !important;
}
td, th {
border-color: #404040 !important;
}
a {
color: #64b5f6 !important;
}
""" else ""
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
padding: 16px;
line-height: 1.6;
color: #333;
}
.version-banner {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
}
.content {
max-width: 800px;
margin: 0 auto;
}
p { margin: 8px 0; }
h1, h2, h3, h4, h5, h6 { margin: 16px 0 8px 0; }
ul, ol { padding-left: 20px; }
pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
blockquote { border-left: 3px solid #ccc; padding-left: 12px; margin: 8px 0; color: #666; }
img { max-width: 100%; height: auto; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; }
th { background: #f4f4f4; }
a { color: #0066cc; }
$darkModeCss
</style>
</head>
<body>
<div class="version-banner">
<strong>Versión de solo lectura</strong>
</div>
<div class="content">
$htmlContent
</div>
</body>
</html>
""".trimIndent()
webView.loadDataWithBaseURL(
"${creds?.url}/",
fullHtml,
"text/html",
"UTF-8",
null
)
currentPageContent = htmlContent
// Mostrar barra de acción con info del usuario y botón
actionBar.visibility = View.VISIBLE
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
progress.visibility = View.GONE
webView.loadData(
"<html><body><h1>Error</h1><p>${e.message}</p></body></html>",
"text/html",
"UTF-8"
)
}
}
}
}
private fun loadVersionInEditor(pageId: String, historyId: String) {
val resultIntent = Intent()
resultIntent.putExtra("loadVersion", true)
resultIntent.putExtra("pageId", pageId)
resultIntent.putExtra("historyId", historyId)
resultIntent.putExtra("tipTapJson", originalTipTapJson)
setResult(RESULT_OK, resultIntent)
finish()
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
setupWebView(
intent.getStringExtra("pageId") ?: return,
intent.getStringExtra("historyId") ?: return
)
}
}

View File

@@ -0,0 +1,189 @@
package com.docmost.app.utils
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
object ContentConverter {
private val gson = Gson()
fun tipTapJsonToHtml(tipTapJson: String): String {
return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type
val doc = gson.fromJson<Map<String, Any?>>(tipTapJson, mapType)
nodeToHtml(doc)
} catch (e: Exception) {
tipTapJson
}
}
fun tipTapToHtml(tipTapJson: String): String {
return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type
val doc = gson.fromJson<Map<String, Any?>>(tipTapJson, mapType)
nodeToHtml(doc)
} catch (e: Exception) {
tipTapJson
}
}
private fun nodeToHtml(node: Map<String, Any?>): String {
val type = node["type"] as? String
if (type == "text") {
var text = (node["text"] as? String ?: "").escapeHtml()
val marks = node["marks"] as? List<Map<String, Any?>>
marks?.forEach { mark ->
when (mark["type"] as? String) {
"bold" -> text = "<strong>$text</strong>"
"italic" -> text = "<em>$text</em>"
"code" -> text = "<code>$text</code>"
"strike" -> text = "<del>$text</del>"
"link" -> {
val href = (mark["attrs"] as? Map<*, *>)?.get("href") as? String ?: ""
text = "<a href=\"${href.escapeHtml()}\" target=\"_blank\">$text</a>"
}
}
}
return text
}
if (type == "hardBreak") {
return "<br>"
}
val sb = StringBuilder()
val content = node["content"] as? List<*>
when (type) {
"doc" -> {
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
}
"paragraph" -> {
sb.append("<p>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</p>")
}
"heading" -> {
val level = (node["attrs"] as? Map<*, *>)?.get("level") as? Int ?: 1
sb.append("<h$level>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</h$level>")
}
"bulletList" -> {
sb.append("<ul>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</ul>")
}
"orderedList" -> {
sb.append("<ol>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</ol>")
}
"listItem" -> {
sb.append("<li>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</li>")
}
"codeBlock" -> {
sb.append("<pre><code>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</code></pre>")
}
"blockquote" -> {
sb.append("<blockquote>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</blockquote>")
}
"image" -> {
val src = (node["attrs"] as? Map<*, *>)?.get("src") as? String ?: ""
val alt = (node["attrs"] as? Map<*, *>)?.get("alt") as? String ?: ""
sb.append("<img src=\"${src.escapeHtml()}\" alt=\"${alt.escapeHtml()}\">")
}
"table" -> {
sb.append("<table>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</table>")
}
"tableRow" -> {
sb.append("<tr>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</tr>")
}
"tableCell", "tableHeader" -> {
val tag = if (type == "tableHeader") "th" else "td"
sb.append("<$tag>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</$tag>")
}
"horizontalRule" -> {
sb.append("<hr>")
}
}
return sb.toString()
}
private fun String.escapeHtml(): String {
return this
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
}

View File

@@ -0,0 +1,144 @@
package com.docmost.app.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.widget.ImageView
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.ImageRequest
import com.docmost.app.R
import com.docmost.app.data.SessionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
object ImageLoader {
private var instance: ImageLoader? = null
private var sessionManager: SessionManager? = null
fun init(context: Context, sessionManager: SessionManager) {
this.sessionManager = sessionManager
val cacheDir = File(context.cacheDir, "image_cache")
instance = ImageLoader.Builder(context)
.memoryCache {
MemoryCache.Builder(context)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir)
.maxSizePercent(0.25)
.build()
}
.okHttpClient {
okhttp3.OkHttpClient.Builder()
.addInterceptor { chain ->
val original = chain.request()
val creds = sessionManager.getCredentials()
val newRequest = if (creds?.authToken != null) {
original.newBuilder()
.addHeader("Cookie", "authToken=${creds.authToken}")
.build()
} else {
original
}
chain.proceed(newRequest)
}
.build()
}
.build()
}
suspend fun load(
url: String,
imageView: ImageView,
fallback: Int = R.drawable.ic_page
) {
withContext(Dispatchers.Main) {
val loader = instance ?: run {
imageView.setImageResource(fallback)
return@withContext
}
val request = ImageRequest.Builder(imageView.context)
.data(url)
.target(
onSuccess = { result ->
imageView.setImageDrawable(result)
},
onError = {
imageView.setImageResource(fallback)
}
)
.build()
loader.enqueue(request)
}
}
fun loadSync(
url: String,
imageView: ImageView,
fallback: Int = R.drawable.ic_page
) {
val loader = instance ?: run {
imageView.setImageResource(fallback)
return
}
val request = ImageRequest.Builder(imageView.context)
.data(url)
.target(
onSuccess = { result ->
imageView.setImageDrawable(result)
},
onError = {
imageView.setImageResource(fallback)
}
)
.build()
loader.enqueue(request)
}
fun loadWithBitmap(
url: String,
imageView: ImageView,
onBitmapLoaded: ((Bitmap) -> Unit)? = null,
fallback: Int = R.drawable.ic_page
) {
val loader = instance ?: run {
imageView.setImageResource(fallback)
return
}
val request = ImageRequest.Builder(imageView.context)
.data(url)
.target(
onSuccess = { result ->
imageView.setImageDrawable(result)
val bitmap = drawableToBitmap(result)
bitmap?.let { onBitmapLoaded?.invoke(it) }
},
onError = {
imageView.setImageResource(fallback)
}
)
.build()
loader.enqueue(request)
}
private fun drawableToBitmap(drawable: Drawable): Bitmap? {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
return null
}
}

View File

@@ -0,0 +1,120 @@
package com.docmost.app.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
enum class NetworkStatus {
ONLINE,
OFFLINE,
SYNCING
}
class NetworkConnectivityObserver(private val context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _networkStatus = MutableStateFlow(checkNetworkStatus())
val networkStatus: StateFlow<NetworkStatus> = _networkStatus.asStateFlow()
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_networkStatus.value = NetworkStatus.ONLINE
}
override fun onLost(network: Network) {
_networkStatus.value = NetworkStatus.OFFLINE
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isOnline = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
_networkStatus.value = if (isOnline) NetworkStatus.ONLINE else NetworkStatus.OFFLINE
}
}
init {
registerNetworkCallback()
}
private fun registerNetworkCallback() {
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
private fun checkNetworkStatus(): NetworkStatus {
return try {
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
val hasInternet = capabilities?.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) == true
if (hasInternet) NetworkStatus.ONLINE else NetworkStatus.OFFLINE
} catch (e: Exception) {
NetworkStatus.OFFLINE
}
}
fun isOnline(): Boolean {
return _networkStatus.value == NetworkStatus.ONLINE
}
fun isOffline(): Boolean {
return _networkStatus.value == NetworkStatus.OFFLINE
}
fun setSyncing(isSyncing: Boolean) {
_networkStatus.value = if (isSyncing) NetworkStatus.SYNCING else checkNetworkStatus()
}
fun observeNetworkStatus(): Flow<NetworkStatus> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.ONLINE)
}
override fun onLost(network: Network) {
trySend(NetworkStatus.OFFLINE)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isOnline = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
trySend(if (isOnline) NetworkStatus.ONLINE else NetworkStatus.OFFLINE)
}
}
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(networkRequest, callback)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()
fun cleanup() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (e: Exception) {
// Already unregistered
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceVariant" />
<corners android:radius="28dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
</shape>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorPrimary">
<path
android:fillColor="#FFFFFF"
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM17,17L7,17v-2h10v2zM17,13L7,13v-2h10v2zM17,9L7,9L7,7h10v2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4V7H7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9H7c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2H8v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4V17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8l-6,-6zM6,20V4h7v5h5v11H6z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9H1l3.89,3.89l0.07,0.14L9,12H6c0,-3.87 3.13,-7 7,-7s7,3.13 7,7s-3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54l0.72,-1.21l-3.5,-2.08V8H12z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M2.01,21L23,12L2.01,3L2,10L17,12L2,14L2.01,21Z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M4,4h7v7H4V4zM4,13h7v7H4v-7zM13,4h7v7h-7V4zM13,13h7v7h-7v-7z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_horizontal"
android:padding="24dp">
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_docmost"
app:tint="?attr/colorPrimary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/app_subtitle"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/url_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:autofillHints="url" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/email_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:autofillHints="emailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:hint="@string/password_hint"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:autofillHints="password" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConnect"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="@string/connect"
android:textSize="16sp" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View File

@@ -0,0 +1,569 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimaryContainer"
app:tabTextColor="?attr/colorOnPrimaryContainer"
app:tabIndicatorColor="?attr/colorOnPrimaryContainer"
app:tabSelectedTextColor="?attr/colorOnPrimaryContainer" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/readOnlyTabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimaryContainer"
app:tabTextColor="?attr/colorOnPrimaryContainer"
app:tabIndicatorColor="?attr/colorOnPrimaryContainer"
app:tabSelectedTextColor="?attr/colorOnPrimaryContainer"
android:visibility="gone" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/readOnlyViewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<FrameLayout
android:id="@+id/editorContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/pageContent"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webViewPreview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<LinearLayout
android:id="@+id/editorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="visible">
<LinearLayout
android:id="@+id/titleContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="12dp">
<TextView
android:id="@+id/tvEmoji"
android:layout_width="56dp"
android:layout_height="56dp"
android:gravity="center"
android:textSize="28sp"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground"
android:text="@string/add_emoji"
android:contentDescription="@string/choose_emoji" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/title_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textSize="18sp"
android:textStyle="bold"
android:maxLines="3" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<WebView
android:id="@+id/webViewEditor"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToOutline="false" />
</LinearLayout>
</FrameLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/tvOfflineBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/offline_banner"
android:textSize="12sp"
android:textColor="?attr/colorOnPrimaryContainer"
android:background="?attr/colorPrimaryContainer"
android:gravity="center"
android:padding="6dp"
android:visibility="gone" />
<FrameLayout
android:id="@+id/commentsContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvComments"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="8dp" />
<TextView
android:id="@+id/emptyComments"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/no_comments"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressComments"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:background="?attr/colorSurfaceVariant">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/commentInputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/write_comment"
app:boxStrokeWidth="1dp"
app:boxStrokeWidthFocused="1dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCommentInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textSize="14sp"
android:maxLines="3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSendComment"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:layout_gravity="center"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:icon="@drawable/ic_send"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="20dp"
app:cornerRadius="24dp"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
<FrameLayout
android:id="@+id/indexContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvIndex"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
<TextView
android:id="@+id/emptyIndex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_headings"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressIndex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabToggleMode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@drawable/ic_pencil"
android:contentDescription="@string/edit"
android:visibility="gone"
app:fabSize="normal" />
<FrameLayout
android:id="@+id/optionsContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/infoHeaderCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<TextView
android:id="@+id/btnToggleInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=" Información"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:drawableEnd="@drawable/ic_expand_more"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/infoCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Creado por:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:id="@+id/creatorInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivCreator"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<TextView
android:id="@+id/tvCreator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?attr/colorOutline" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Última actualización por:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:id="@+id/updaterInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivUpdater"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<TextView
android:id="@+id/tvUpdater"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="?attr/colorOutline" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📊 Estadísticas:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvWordCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvCharCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvCreatedAt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvUpdatedAt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface" />
<View
android:id="@+id/labelsDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="?attr/colorOutline"
android:visibility="gone" />
<TextView
android:id="@+id/tvLabelsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🏷️ Labels:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/labelsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/historyHeaderCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<TextView
android:id="@+id/btnToggleHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📜 Historial"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:drawableEnd="@drawable/ic_expand_more"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/historyCard"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:visibility="gone"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvHistory"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
<TextView
android:id="@+id/emptyHistory"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="No hay historial disponible"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressOptions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardExportPdf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<TextView
android:id="@+id/btnExportPdf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📄 Exportar a PDF"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</FrameLayout>
</FrameLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_margin="16dp"
android:text="@string/save"
android:textSize="16sp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,598 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:tabIndicatorColor="?attr/colorPrimary"
app:tabMode="fixed"
app:tabGravity="fill" />
<TextView
android:id="@+id/tvOfflineBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FF9800"
android:textColor="#000000"
android:textSize="14sp"
android:textStyle="bold"
android:padding="12dp"
android:gravity="center"
android:visibility="gone" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ImageView
android:id="@+id/ivBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:alpha="0.3"
android:visibility="gone" />
<FrameLayout
android:id="@+id/spacesContent"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeSpaces"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="16dp" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
android:lineSpacingExtra="4dp" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
<FrameLayout
android:id="@+id/recentContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRecent"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvRecentPages"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp" />
<TextView
android:id="@+id/emptyRecent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
android:lineSpacingExtra="4dp" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressRecent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
<FrameLayout
android:id="@+id/sharesContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeShares"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvShares"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp"
android:paddingBottom="80dp" />
<TextView
android:id="@+id/emptyShares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
android:lineSpacingExtra="4dp" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressShares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNewShare"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="16dp"
android:text="@string/new_share_link"
android:icon="@drawable/ic_add"
style="@style/Widget.Material3.Button.TonalButton" />
</FrameLayout>
<ScrollView
android:id="@+id/settingsContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Profile -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/profile"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAvatar"
android:layout_width="64dp"
android:layout_height="64dp"
app:shapeAppearanceOverlay="@style/CircularImageView"
android:scaleType="centerCrop"
android:src="@drawable/ic_page" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangeAvatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/change_photo"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRemoveAvatar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/remove_photo"
android:visibility="gone"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/name_hint"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUserName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveProfile"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/save_changes" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Appearance -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/appearance"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/background_image"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/background_image_description"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangeBackground"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/change"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRemoveBackground"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/remove_background_image"
android:visibility="gone"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Account -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/account"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvEmail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLogout"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/logout"
style="@style/Widget.Material3.Button.OutlinedButton"
app:strokeColor="?attr/colorError"
android:textColor="?attr/colorError" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<include
android:id="@+id/userCard"
layout="@layout/item_user_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddAccount"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/add_account"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_marginBottom="16dp" />
<!-- Offline -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/offline_mode"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/offline_hint"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchForcedOffline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/force_offline"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCachedPages"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/cached_pages"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Deleted Pages -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/deleted_pages"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/deleted_pages_description"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDeletedPages"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/deleted_pages_button"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Biometric -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/biometric_lock"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="12dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchBiometric"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/biometric_summary"
android:textSize="14sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAddSpace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="88dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/create_space"
android:src="@drawable/ic_add"
app:backgroundTint="?attr/colorPrimaryContainer"
app:tint="?attr/colorOnPrimaryContainer" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/search"
android:src="@drawable/ic_search"
app:backgroundTint="?attr/colorPrimaryContainer"
app:tint="?attr/colorOnPrimaryContainer" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<LinearLayout
android:id="@+id/actionBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:background="?attr/colorSurface"
android:elevation="2dp"
android:gravity="center_vertical"
android:visibility="gone">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAvatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="8dp">
<TextView
android:id="@+id/tvAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvDateTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLoadVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cargar esta versión"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webViewVersion"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_url_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/account_email_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAccountEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/account_password_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:passwordToggleEnabled="true"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAccountPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Acciones"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/btnContextDuplicate"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="@string/duplicate"
android:textSize="16sp"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="8dp" />
<TextView
android:id="@+id/btnContextMove"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="@string/move_to_space"
android:textSize="16sp"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="8dp" />
<TextView
android:id="@+id/btnContextMoveToPage"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="@string/move_to_page"
android:textSize="16sp"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="8dp" />
<TextView
android:id="@+id/btnContextCopyLink"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="@string/copy_link"
android:textSize="16sp"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="8dp" />
</LinearLayout>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/page_title"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPageTitle"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:boxBackgroundMode="outline"
app:boxStrokeColor="?attr/colorPrimary"
app:boxStrokeWidth="2dp"
app:boxCornerRadiusTopStart="8dp"
app:boxCornerRadiusTopEnd="8dp"
app:boxCornerRadiusBottomStart="8dp"
app:boxCornerRadiusBottomEnd="8dp"
app:hintEnabled="false"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPageTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:textSize="16sp"
android:hint="@string/page_title" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/offline_hint"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="16dp"
android:paddingStart="8dp"
android:paddingEnd="8dp" />
</LinearLayout>

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<ImageView
android:id="@+id/ivSpaceIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_page" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChooseIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp"
android:text="@string/choose_icon"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/space_name"
android:layout_marginBottom="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSpaceName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/space_description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSpaceDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2"
android:maxLines="4"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvDeletedPages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="400dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/tvEmptyDeleted"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="No hay páginas eliminadas"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
android:padding="32dp" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressDeleted"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<ImageView
android:id="@+id/ivIconPreview"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
android:contentDescription="Icono del espacio"
android:background="@drawable/ic_page"
android:padding="4dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangeIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cambiar icono"
style="@style/Widget.Material3.Button.TextButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRemoveIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Eliminar icono"
style="@style/Widget.Material3.Button.TextButton"
android:textColor="?attr/colorError"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilSpaceName"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Nombre"
android:layout_marginBottom="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSpaceName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilSpaceDescription"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Descripción">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSpaceDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:maxLines="3"
android:gravity="top" />
</com.google.android.material.textfield.TextInputLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="16dp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDeleteSpace"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/delete_space"
style="@style/Widget.Material3.Button.OutlinedButton"
app:strokeColor="?attr/colorError"
android:textColor="?attr/colorError" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/choose_emoji"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<GridView
android:id="@+id/emojiGrid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:numColumns="6"
android:horizontalSpacing="8dp"
android:verticalSpacing="8dp"
android:gravity="center"
android:stretchMode="columnWidth" />
<Button
android:id="@+id/btnRemoveEmoji"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/remove_emoji"
style="?android:attr/borderlessButtonStyle" />
</LinearLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="URL del enlace">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLinkUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:text="https://" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Texto del enlace">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLinkText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/select_target_page"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<EditText
android:id="@+id/etSearchPage"
android:layout_width="match_parent"
android:layout_height="48dp"
android:hint="@string/search_pages"
android:inputType="text"
android:paddingHorizontal="12dp"
android:background="@android:drawable/editbox_background"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/btnMoveToRoot"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:text="@string/move_to_root"
android:textSize="16sp"
android:textStyle="italic"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="8dp"
android:layout_marginBottom="8dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
android:layout_marginBottom="8dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvPageList"
android:layout_width="match_parent"
android:layout_height="300dp" />
<TextView
android:id="@+id/tvEmptyPages"
android:layout_width="match_parent"
android:layout_height="100dp"
android:gravity="center"
android:text="@string/no_pages_found"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="56dp"
android:hint="@string/search"
android:inputType="text"
android:textSize="16sp"
android:background="@drawable/bg_search"
android:drawableStart="@drawable/ic_search"
android:drawablePadding="12dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:maxLines="1"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/tvSearchEmpty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
android:lineSpacingExtra="4dp"
android:padding="32dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearchResults"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp" />
</LinearLayout>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/select_target_space"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSpaceList"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<WebView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/webViewContent"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/emptyIndex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="No hay encabezados en esta página"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvIndex"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
</LinearLayout>

View File

@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/infoHeaderCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<TextView
android:id="@+id/btnToggleInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=" Información"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:drawableEnd="@drawable/ic_expand_more"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/infoCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Creado por:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:id="@+id/creatorInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivCreator"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<TextView
android:id="@+id/tvCreator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?attr/colorOutline" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Última actualización por:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:id="@+id/updaterInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivUpdater"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<TextView
android:id="@+id/tvUpdater"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="8dp"
android:background="?attr/colorOutline" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Fecha de creación:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvCreatedAt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvUpdatedAt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/historyHeaderCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<TextView
android:id="@+id/btnToggleHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📜 Historial"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:drawableEnd="@drawable/ic_expand_more"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/historyCard"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:visibility="gone"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvHistory"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
<TextView
android:id="@+id/emptyHistory"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="No hay historial disponible"
android:textSize="16sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressOptions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardExportPdf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurface">
<TextView
android:id="@+id/btnExportPdf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📄 Exportar a PDF"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:background="?attr/selectableItemBackground"
android:padding="12dp"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="56dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/tvAddIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:gravity="center"
android:text="+"
android:textSize="16sp"
android:textColor="?attr/colorPrimary" />
<TextView
android:id="@+id/tvAddPage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_page"
android:textSize="14sp"
android:textColor="?attr/colorPrimary" />
</LinearLayout>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/ivCommentAvatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_page" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvCommentAuthor"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvCommentTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<TextView
android:id="@+id/tvCommentContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<TextView
android:id="@+id/btnEditComment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Editar"
android:textSize="12sp"
android:textColor="?attr/colorPrimary"
android:paddingEnd="12dp"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground" />
<TextView
android:id="@+id/btnDeleteComment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Eliminar"
android:textSize="12sp"
android:textColor="?attr/colorError"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvPageIcon"
android:layout_width="40dp"
android:layout_height="40dp"
android:gravity="center"
android:textSize="24sp"
android:text="📄" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvPageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvDeletedAt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRestore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Restaurar"
style="@style/Widget.Material3.Button.TonalButton"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/historyItem"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvDateTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<ImageView
android:id="@+id/btnViewVersion"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_visibility"
android:contentDescription="Ver versión"
android:padding="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutline" />
</LinearLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground"
android:paddingVertical="10dp">
<TextView
android:id="@+id/tvIndexIcon"
android:layout_width="24dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorPrimary"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvIndexText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/pageItemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="24dp"
android:paddingTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp"
android:background="?attr/selectableItemBackground">
<ImageButton
android:id="@+id/btnExpandPage"
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/ic_expand_more"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Toggle children"
android:scaleType="center"
android:visibility="gone" />
<TextView
android:id="@+id/tvPageIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textSize="14sp"
android:visibility="gone" />
<ImageView
android:id="@+id/ivPageIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_page"
android:contentDescription="@string/page"
app:tint="?attr/colorOnSurfaceVariant"
android:visibility="visible" />
<TextView
android:id="@+id/tvPageTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<ImageButton
android:id="@+id/btnDeletePage"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_delete"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/delete"
android:scaleType="center"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvPageIcon"
android:layout_width="36dp"
android:layout_height="36dp"
android:gravity="center"
android:textSize="18sp"
android:visibility="gone" />
<ImageView
android:id="@+id/ivPageIcon"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_page"
android:scaleType="center"
app:tint="?attr/colorOnSurfaceVariant"
android:visibility="visible" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvPageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvPageDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="4dp"
android:maxLines="1" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAccountAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
app:shapeAppearanceOverlay="@style/CircularImageView"
android:scaleType="centerCrop"
android:src="@drawable/ic_page" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvAccountEmail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvAccountUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginTop="4dp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSwitchAccount"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/switch_account"
android:textSize="12sp"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_marginStart="8dp" />
<ImageButton
android:id="@+id/btnRemoveAccount"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_delete"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/remove_account"
android:layout_marginStart="4dp"
app:tint="?attr/colorError" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:background="?attr/selectableItemBackground">
<TextView
android:id="@+id/tvShareIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:gravity="center"
android:text="🔗"
android:textSize="16sp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvSharePageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvShareUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="middle" />
</LinearLayout>
<ImageButton
android:id="@+id/btnCopyShareLink"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_link"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/copy_share_link"
android:scaleType="center"
android:visibility="gone" />
<ImageButton
android:id="@+id/btnDeleteShare"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_delete"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/delete"
android:scaleType="center" />
</LinearLayout>

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvShareIcon"
android:layout_width="36dp"
android:layout_height="36dp"
android:gravity="center"
android:textSize="18sp"
android:visibility="gone" />
<ImageView
android:id="@+id/ivShareIcon"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_page"
android:scaleType="center"
app:tint="?attr/colorOnSurfaceVariant"
android:visibility="visible" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvSharePageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvShareUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="4dp"
android:maxLines="1"
android:ellipsize="middle" />
</LinearLayout>
<ImageButton
android:id="@+id/btnCopyShareLink"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_link"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/copy_share_link"
android:scaleType="center" />
<ImageButton
android:id="@+id/btnDeleteShare"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_delete"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/delete"
android:scaleType="center" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:strokeWidth="0dp"
app:strokeColor="@android:color/transparent"
style="@style/Widget.MaterialComponents.CardView"
android:clipToPadding="false">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/ivBlurredBg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:visibility="gone" />
<View
android:id="@+id/vScrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp">
<ImageButton
android:id="@+id/btnExpand"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Expandir"
android:src="@drawable/ic_expand_more"
android:scaleType="center"
android:tint="?attr/colorOnSurfaceVariant" />
<ImageView
android:id="@+id/ivSpaceIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginStart="8dp"
android:scaleType="centerCrop"
android:contentDescription="@string/space_icon"
android:visibility="gone" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvSpaceName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvRole"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textAllCaps="true"
android:letterSpacing="0.05"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:layout_marginStart="6dp"
android:textColor="?attr/colorPrimary"
android:background="?attr/colorPrimaryContainer"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvSpaceDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="2"
android:ellipsize="end"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvPageCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
android:layout_marginEnd="4dp" />
<ImageButton
android:id="@+id/btnEditSpace"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Editar espacio"
android:src="@drawable/ic_pencil"
android:scaleType="center"
android:tint="?attr/colorOnSurfaceVariant"
android:layout_marginStart="4dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/pagesContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp" />
</LinearLayout>
<View
android:id="@+id/ivGradientBorder"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="16dp"
android:paddingBottom="4dp">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" />
<TextView
android:id="@+id/tvHeaderLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurfaceVariant"
android:letterSpacing="0.04"
android:paddingTop="12dp"
android:paddingBottom="4dp" />
</LinearLayout>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:alpha="0.88"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:id="@+id/headerUserCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/usuarios"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvUserCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginEnd="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/accountList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Docmost</string>
<string name="app_subtitle">Cliente para Docmost Wiki</string>
<string name="url_hint">URL del servidor (ej. https://wiki.ejemplo.com)</string>
<string name="email_hint">Correo electrónico</string>
<string name="password_hint">Contraseña</string>
<string name="connect">Conectar</string>
<string name="connecting">Conectando…</string>
<string name="fill_all_fields">Completa todos los campos</string>
<string name="no_spaces">No hay spaces disponibles</string>
<string name="error_loading">Error al cargar datos</string>
<string name="editing_page">Editando página</string>
<string name="title_hint">Título</string>
<string name="content_hint">Contenido (Markdown)</string>
<string name="save">Guardar</string>
<string name="saving">Guardando…</string>
<string name="page_saved">Página guardada</string>
<string name="logout">Cerrar sesión</string>
<string name="logout_confirm">¿Estás seguro de cerrar sesión?</string>
<string name="cancel">Cancelar</string>
<string name="page">Página</string>
<string name="search">Buscar</string>
<string name="preview">Vista previa</string>
<string name="edit">Editar</string>
<string name="space_icon">Icono del espacio</string>
<string name="add_emoji"></string>
<string name="remove_emoji">Quitar emoji</string>
<string name="choose_emoji">Elegir emoji</string>
<string name="spaces_tab">Espacios</string>
<string name="settings_tab">Ajustes</string>
<string name="profile">Perfil</string>
<string name="account">Cuenta</string>
<string name="name_hint">Nombre</string>
<string name="change_photo">Cambiar foto</string>
<string name="remove_photo">Eliminar foto</string>
<string name="save_changes">Guardar cambios</string>
<string name="profile_updated">Perfil actualizado</string>
<string name="create_space">Crear espacio</string>
<string name="space_name">Nombre del espacio</string>
<string name="space_slug">Slug del espacio</string>
<string name="space_description">Descripción (opcional)</string>
<string name="creating">Creando…</string>
<string name="space_created">Espacio creado</string>
<string name="choose_icon">Elegir icono</string>
<string name="recent_tab">Recientes</string>
<string name="no_recent_pages">No hay páginas recientes</string>
<string name="add_page">+ Nueva página</string>
<string name="page_title">Título de la página</string>
<string name="create">Crear</string>
<string name="delete">Eliminar</string>
<string name="delete_confirm">¿Estás seguro de eliminar esta página?</string>
<string name="deleting">Eliminando…</string>
<string name="delete_space">Eliminar espacio</string>
<string name="delete_space_confirm">¿Estás seguro de eliminar este espacio? Se eliminarán todas las páginas que contiene.</string>
<string name="duplicate">Duplicar</string>
<string name="move_to_space">Mover a otro espacio…</string>
<string name="move_to_page">Mover a otra página…</string>
<string name="select_target_page">Seleccionar página padre</string>
<string name="search_pages">Buscar páginas…</string>
<string name="no_pages_found">No se encontraron páginas</string>
<string name="move_to_root">Mover a raíz del espacio</string>
<string name="copy_link">Copiar enlace</string>
<string name="link_copied">Enlace copiado al portapapeles</string>
<string name="select_target_space">Seleccionar espacio de destino</string>
<string name="page_duplicated">Página duplicada</string>
<string name="page_moved">Página movida</string>
<string name="offline_mode">Modo sin conexión</string>
<string name="offline_banner">Viendo versión sin conexión</string>
<string name="cached_pages">Páginas sin conexión</string>
<string name="no_cached_pages">No hay páginas guardadas sin conexión</string>
<string name="offline_hint">Las páginas que visites se guardan automáticamente</string>
<string name="open_cached">Abrir sin conexión</string>
<string name="force_offline">Forzar modo sin conexión</string>
<string name="offline_not_available">No disponible sin conexión</string>
<string name="shares_tab">Compartir</string>
<string name="share_page">Compartir página</string>
<string name="no_shares">No hay páginas compartidas</string>
<string name="share_created">Enlace compartido creado</string>
<string name="share_deleted">Enlace compartido eliminado</string>
<string name="copy_share_link">Copiar enlace</string>
<string name="select_page_to_share">Seleccionar página para compartir</string>
<string name="search_page_to_share">Buscar página para compartir</string>
<string name="confirm_share">¿Compartir esta página?</string>
<string name="share_url_hint">Enlace público</string>
<string name="new_share_link">Nuevo enlace compartido</string>
<string name="biometric_lock">Bloqueo biométrico</string>
<string name="biometric_summary">Requerir autenticación biométrica al abrir la app</string>
<string name="biometric_title">Autenticación requerida</string>
<string name="biometric_subtitle">Desbloquea la app para continuar</string>
<string name="biometric_not_available">La autenticación biométrica no está disponible en este dispositivo</string>
<string name="send">Enviar</string>
<string name="update">Actualizar</string>
<string name="no_comments">Sin comentarios</string>
<string name="no_headings">Sin encabezados</string>
<string name="write_comment">Escribir comentario…</string>
<string name="quick_user_switch">Cambio rápido de usuario</string>
<string name="add_account">+ Añadir cuenta</string>
<string name="switch_account">Cambiar</string>
<string name="current_account">Actual</string>
<string name="remove_account">Eliminar cuenta</string>
<string name="add_account_title">Añadir cuenta</string>
<string name="server_url_hint">URL del servidor</string>
<string name="account_email_hint">Email de la cuenta</string>
<string name="account_password_hint">Contraseña</string>
<string name="account_saved">Cuenta guardada</string>
<string name="account_already_exists">Esta cuenta ya está guardada</string>
<string name="account_removed">Cuenta eliminada</string>
<string name="switched_to">Cambiado a %s</string>
<string name="error_switching_account">Error al cambiar: %s</string>
<string name="usuarios">Usuarios</string>
<string name="appearance">Apariencia</string>
<string name="background_image">Imagen de fondo</string>
<string name="background_image_description">El blur se ajusta según el tema</string>
<string name="change">Cambiar</string>
<string name="remove_background_image">Quitar imagen de fondo</string>
<string name="deleted_pages">Páginas eliminadas</string>
<string name="deleted_pages_description">Ver y restaurar páginas eliminadas recientemente</string>
<string name="deleted_pages_button">🗑️ Páginas eliminadas</string>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Docmost" parent="Theme.Material3.DynamicColors.DayNight">
<item name="android:statusBarColor">?attr/colorPrimary</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
<item name="android:windowLightNavigationBar">?attr/isLightTheme</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">?attr/colorSurface</item>
</style>
<style name="CircularImageView" parent="">
<item name="cornerSize">50%</item>
</style>
</resources>

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.1.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
}

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.caching=true
android.enableR8.fullMode=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

244
gradlew vendored Normal file
View File

@@ -0,0 +1,244 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

16
settings.gradle.kts Normal file
View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Docmost"
include(":app")